Kitty Graphics Protocol in OCaml
terminal graphics ocaml

initial import

+1
.gitignore
···
+
_build
+11
dune-project
···
+
(lang dune 3.20)
+
(name kitty_graphics)
+
+
(package
+
(name kitty_graphics)
+
(synopsis "OCaml implementation of the Kitty terminal graphics protocol")
+
(description
+
"A standalone library for rendering images in terminals that support the Kitty graphics protocol. Supports image transmission, display, animation, Unicode placeholders, and terminal capability detection.")
+
(depends
+
(ocaml (>= 4.14.0))
+
base64))
+97
example/anim_test.ml
···
+
(* Minimal animation test - shows exact bytes sent *)
+
+
module K = Kitty_graphics
+
+
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
+
let pixels = Bytes.create (width * height * 4) in
+
for i = 0 to (width * height) - 1 do
+
let idx = i * 4 in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr g);
+
Bytes.set pixels (idx + 2) (Char.chr b);
+
Bytes.set pixels (idx + 3) (Char.chr a)
+
done;
+
Bytes.to_string pixels
+
+
let send cmd ~data =
+
print_string (K.Command.to_string cmd ~data);
+
flush stdout
+
+
let () =
+
let width, height = 40, 40 in (* Smaller for faster testing *)
+
let image_id = 500 in
+
+
(* Clear any existing image *)
+
send (K.Command.delete ~quiet:`Errors_only (`All_visible_and_free)) ~data:"";
+
+
(* Step 1: Transmit base frame (red) *)
+
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~quiet:`Errors_only
+
())
+
~data:red_frame;
+
+
(* Step 2: Add frame (blue) *)
+
let blue_frame = solid_color_rgba ~width ~height ~r:0 ~g:0 ~b:255 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:blue_frame;
+
+
(* Step 3: Add frame (green) *)
+
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:500 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:green_frame;
+
+
(* Step 4: Create placement *)
+
send
+
(K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cursor:`Static
+
())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
+
(* Step 5: Set root frame gap - IMPORTANT: root frame has no gap by default *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:500))
+
~data:"";
+
+
(* Step 6: Start animation *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
+
~data:"";
+
+
print_endline "";
+
print_endline "Animation should be playing (red -> blue -> green).";
+
print_endline "Press Enter to stop...";
+
flush stdout;
+
let _ = read_line () in
+
+
(* Stop animation *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
+
~data:"";
+
+
(* Clean up *)
+
send (K.Command.delete ~quiet:`Errors_only (`All_visible_and_free)) ~data:"";
+
print_endline "Done."
+94
example/debug_anim.ml
···
+
(* Debug: Output animation escape sequences for comparison with Go *)
+
+
module K = Kitty_graphics
+
+
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
+
let pixels = Bytes.create (width * height * 4) in
+
for i = 0 to (width * height) - 1 do
+
let idx = i * 4 in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr g);
+
Bytes.set pixels (idx + 2) (Char.chr b);
+
Bytes.set pixels (idx + 3) (Char.chr a)
+
done;
+
Bytes.to_string pixels
+
+
let send cmd ~data =
+
let s = K.Command.to_string cmd ~data in
+
(* Print escaped version for debugging *)
+
String.iter (fun c ->
+
let code = Char.code c in
+
if code = 27 then print_string "\\x1b"
+
else if code < 32 || code > 126 then Printf.printf "\\x%02x" code
+
else print_char c
+
) s;
+
print_newline ()
+
+
let () =
+
let width, height = 80, 80 in
+
let image_id = 300 in
+
+
print_endline "=== OCaml Animation Debug ===\n";
+
+
(* Step 1: Transmit base frame *)
+
print_endline "1. Transmit base frame (a=t):";
+
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~quiet:`Errors_only
+
())
+
~data:red_frame;
+
print_newline ();
+
+
(* Step 2: Add frame *)
+
print_endline "2. Add frame (a=f):";
+
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:orange_frame;
+
print_newline ();
+
+
(* Step 3: Put/display placement *)
+
print_endline "3. Create placement (a=p):";
+
send
+
(K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cell_x_offset:0
+
~cell_y_offset:0
+
~cursor:`Static
+
())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
print_newline ();
+
+
(* Step 4: Set root frame gap *)
+
print_endline "4. Set root frame gap (a=a,r=1,z=100):";
+
send
+
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100))
+
~data:"";
+
print_newline ();
+
+
(* Step 5: Animate *)
+
print_endline "5. Start animation (a=a,s=3,v=1):";
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
+
~data:"";
+
print_newline ();
+
+
(* Step 6: Stop animation *)
+
print_endline "6. Stop animation:";
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
+
~data:""
+19
example/dune
···
+
(executable
+
(name example)
+
(libraries kitty_graphics unix))
+
+
(executable
+
(name debug_anim)
+
(libraries kitty_graphics))
+
+
(executable
+
(name test_output)
+
(libraries kitty_graphics))
+
+
(executable
+
(name anim_test)
+
(libraries kitty_graphics))
+
+
(executable
+
(name tiny_anim)
+
(libraries kitty_graphics))
+417
example/example.ml
···
+
(* Kitty Graphics Protocol Demo - Matching kgp/examples/demo *)
+
+
module K = Kitty_graphics
+
+
(* Helper: Generate a solid color RGBA image *)
+
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
+
let pixels = Bytes.create (width * height * 4) in
+
for i = 0 to (width * height) - 1 do
+
let idx = i * 4 in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr g);
+
Bytes.set pixels (idx + 2) (Char.chr b);
+
Bytes.set pixels (idx + 3) (Char.chr a)
+
done;
+
Bytes.to_string pixels
+
+
(* Helper: Generate a solid color RGB image (no alpha) *)
+
let solid_color_rgb ~width ~height ~r ~g ~b =
+
let pixels = Bytes.create (width * height * 3) in
+
for i = 0 to (width * height) - 1 do
+
let idx = i * 3 in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr g);
+
Bytes.set pixels (idx + 2) (Char.chr b)
+
done;
+
Bytes.to_string pixels
+
+
(* Helper: Generate a gradient RGBA image *)
+
let gradient_rgba ~width ~height =
+
let pixels = Bytes.create (width * height * 4) in
+
for y = 0 to height - 1 do
+
for x = 0 to width - 1 do
+
let idx = (y * width + x) * 4 in
+
let r = 255 * x / width in
+
let b = 255 * (width - x) / width in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr 128);
+
Bytes.set pixels (idx + 2) (Char.chr b);
+
Bytes.set pixels (idx + 3) '\xff'
+
done
+
done;
+
Bytes.to_string pixels
+
+
(* Helper: Read a file *)
+
let read_file filename =
+
let ic = open_in_bin filename in
+
let n = in_channel_length ic in
+
let s = really_input_string ic n in
+
close_in ic;
+
s
+
+
let send cmd ~data =
+
print_string (K.Command.to_string cmd ~data);
+
flush stdout
+
+
let wait_for_enter () =
+
print_string "Press Enter to continue...";
+
flush stdout;
+
let _ = read_line () in
+
print_newline ()
+
+
let clear_screen () =
+
print_string "\x1b[2J\x1b[H";
+
for _ = 1 to 5 do print_newline () done;
+
flush stdout
+
+
let () =
+
let reader = stdin in
+
ignore reader;
+
+
clear_screen ();
+
print_endline "Kitty Graphics Protocol - OCaml Demo";
+
print_endline "=====================================";
+
print_newline ();
+
print_endline "Press Enter to proceed through each demo...";
+
print_newline ();
+
+
(* Demo 1: Basic formats - PNG *)
+
clear_screen ();
+
print_endline "Demo 1: Image Formats - PNG format";
+
(* Read sf.png and display a small portion as demo *)
+
(try
+
let png_data = read_file "sf.png" in
+
send
+
(K.Command.transmit_and_display
+
~image_id:1
+
~format:`Png
+
~quiet:`Errors_only
+
~placement:(K.Placement.make ~columns:15 ~rows:8 ())
+
())
+
~data:png_data;
+
print_endline "sf.png displayed using PNG format"
+
with _ ->
+
(* Fallback: red square as RGBA *)
+
let red_data = solid_color_rgba ~width:100 ~height:100 ~r:255 ~g:0 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:1
+
~format:`Rgba32
+
~width:100 ~height:100
+
~quiet:`Errors_only
+
())
+
~data:red_data;
+
print_endline "Red square displayed (sf.png not found)");
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 2: Basic formats - RGBA *)
+
clear_screen ();
+
print_endline "Demo 2: Image Formats - RGBA format (32-bit)";
+
let blue_data = solid_color_rgba ~width:100 ~height:100 ~r:0 ~g:0 ~b:255 ~a:255 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:2
+
~format:`Rgba32
+
~width:100 ~height:100
+
~quiet:`Errors_only
+
())
+
~data:blue_data;
+
print_endline "Blue square displayed using raw RGBA format";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 3: Basic formats - RGB *)
+
clear_screen ();
+
print_endline "Demo 3: Image Formats - RGB format (24-bit)";
+
let green_data = solid_color_rgb ~width:100 ~height:100 ~r:0 ~g:255 ~b:0 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:3
+
~format:`Rgb24
+
~width:100 ~height:100
+
~quiet:`Errors_only
+
())
+
~data:green_data;
+
print_endline "Green square displayed using raw RGB format (no alpha channel)";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 4: Compression - Note: would need zlib library for actual compression *)
+
clear_screen ();
+
print_endline "Demo 4: Large Image (compression requires zlib library)";
+
let orange_data = solid_color_rgba ~width:200 ~height:200 ~r:255 ~g:165 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:4
+
~format:`Rgba32
+
~width:200 ~height:200
+
~quiet:`Errors_only
+
())
+
~data:orange_data;
+
Printf.printf "Orange square (200x200) - %d bytes uncompressed\n" (String.length orange_data);
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 5: Load and display external PNG file *)
+
clear_screen ();
+
print_endline "Demo 5: Loading external PNG file (sf.png)";
+
(try
+
let png_data = read_file "sf.png" in
+
send
+
(K.Command.transmit_and_display
+
~image_id:10
+
~format:`Png
+
~quiet:`Errors_only
+
())
+
~data:png_data;
+
print_endline "sf.png loaded and displayed"
+
with Sys_error msg ->
+
Printf.printf "sf.png not found: %s\n" msg);
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 6: Cropping and scaling *)
+
clear_screen ();
+
print_endline "Demo 6: Cropping and Scaling - Display part of an image";
+
let gradient = gradient_rgba ~width:200 ~height:200 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:20
+
~format:`Rgba32
+
~width:200 ~height:200
+
~placement:(K.Placement.make
+
~source_x:50 ~source_y:50
+
~source_width:100 ~source_height:100
+
~columns:10 ~rows:10
+
())
+
~quiet:`Errors_only
+
())
+
~data:gradient;
+
print_endline "Cropped to center 100x100 region of a 200x200 gradient";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 7: Multiple placements *)
+
clear_screen ();
+
print_endline "Demo 7: Multiple Placements - One image, multiple displays";
+
let cyan_data = solid_color_rgba ~width:80 ~height:80 ~r:0 ~g:255 ~b:255 ~a:255 in
+
(* Transmit once with an ID *)
+
send
+
(K.Command.transmit
+
~image_id:100
+
~format:`Rgba32
+
~width:80 ~height:80
+
~quiet:`Errors_only
+
())
+
~data:cyan_data;
+
(* Create first placement *)
+
send
+
(K.Command.display
+
~image_id:100
+
~placement:(K.Placement.make ~columns:10 ~rows:5 ())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
(* Create second placement *)
+
send
+
(K.Command.display
+
~image_id:100
+
~placement:(K.Placement.make ~columns:5 ~rows:3 ())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 8: Multiple placements with spacing *)
+
clear_screen ();
+
print_endline "Demo 8: Multiple Placements with Different Sizes";
+
print_newline ();
+
print_endline "Showing same image at different sizes:";
+
print_newline ();
+
(* Create a gradient square *)
+
let grad_small = gradient_rgba ~width:100 ~height:100 in
+
(* Transmit once *)
+
send
+
(K.Command.transmit
+
~image_id:160
+
~format:`Rgba32
+
~width:100 ~height:100
+
~quiet:`Errors_only
+
())
+
~data:grad_small;
+
(* Place same image three times at different sizes *)
+
send
+
(K.Command.display
+
~image_id:160
+
~placement:(K.Placement.make ~columns:5 ~rows:5 ())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
print_string " ";
+
send
+
(K.Command.display
+
~image_id:160
+
~placement:(K.Placement.make ~columns:8 ~rows:8 ())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
print_string " ";
+
send
+
(K.Command.display
+
~image_id:160
+
~placement:(K.Placement.make ~columns:12 ~rows:12 ())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
print_newline ();
+
print_newline ();
+
print_endline "Small (5x5 cells), Medium (8x8 cells), Large (12x12 cells)";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 9: Z-index layering *)
+
clear_screen ();
+
print_endline "Demo 9: Z-Index Layering - Images above/below text";
+
let bg_data = solid_color_rgba ~width:200 ~height:100 ~r:255 ~g:165 ~b:0 ~a:128 in
+
send
+
(K.Command.transmit_and_display
+
~image_id:200
+
~format:`Rgba32
+
~width:200 ~height:100
+
~placement:(K.Placement.make ~z_index:(-1) ~cursor:`Static ())
+
~quiet:`Errors_only
+
())
+
~data:bg_data;
+
print_endline "This orange square should appear behind the text!";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 10: Query support *)
+
clear_screen ();
+
print_endline "Demo 10: Query Support - Check terminal capabilities";
+
let query_str = K.Detect.make_query () in
+
print_string query_str;
+
flush stdout;
+
print_endline "(Check if your terminal responds with OK)";
+
print_newline ();
+
wait_for_enter ();
+
+
(* Demo 11: Animation - color-changing square *)
+
clear_screen ();
+
print_endline "Demo 11: Animation - Color-changing square";
+
print_endline "Creating animated sequence with 4 colors...";
+
+
let width, height = 80, 80 in
+
let image_id = 300 in
+
+
(* Create base frame (red) - transmit without displaying *)
+
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~quiet:`Errors_only
+
())
+
~data:red_frame;
+
+
(* Add frames with composition replace *)
+
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:orange_frame;
+
+
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:yellow_frame;
+
+
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:green_frame;
+
+
(* Create placement and start animation *)
+
send
+
(K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cell_x_offset:0
+
~cell_y_offset:0
+
~cursor:`Static
+
())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
+
(* Set root frame gap - root frame has no gap by default per Kitty protocol *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100))
+
~data:"";
+
+
(* Start animation with infinite looping *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
+
~data:"";
+
+
print_newline ();
+
print_endline "Animation playing with colors: Red -> Orange -> Yellow -> Green";
+
print_newline ();
+
+
(* Simulate movement by deleting and recreating placement at different positions *)
+
for i = 1 to 7 do
+
Unix.sleepf 0.4;
+
+
(* Delete the current placement *)
+
send
+
(K.Command.delete ~quiet:`Errors_only (`By_id (image_id, Some 1)))
+
~data:"";
+
+
(* Create new placement at next position *)
+
send
+
(K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cell_x_offset:(i * 5)
+
~cell_y_offset:0
+
~cursor:`Static
+
())
+
~quiet:`Errors_only
+
())
+
~data:""
+
done;
+
+
(* Stop the animation *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
+
~data:"";
+
+
print_endline "Animation stopped.";
+
print_newline ();
+
print_newline ();
+
print_endline "Demo complete!";
+
print_newline ();
+
print_endline "For more examples, see the library documentation.";
+
wait_for_enter ()
+59
example/test_output.ml
···
+
(* Simple test to show exact escape sequences without data *)
+
+
module K = Kitty_graphics
+
+
let print_escaped s =
+
String.iter (fun c ->
+
let code = Char.code c in
+
if code = 27 then print_string "\\x1b"
+
else if code < 32 || code > 126 then Printf.printf "\\x%02x" code
+
else print_char c
+
) s;
+
print_newline ()
+
+
let () =
+
let image_id = 300 in
+
let width, height = 80, 80 in
+
+
print_endline "=== Animation Escape Sequences (no data) ===\n";
+
+
(* 1. Transmit base frame (no data for testing) *)
+
print_endline "1. Transmit (a=t):";
+
let cmd1 = K.Command.transmit
+
~image_id ~format:`Rgba32 ~width ~height ~quiet:`Errors_only () in
+
print_escaped (K.Command.to_string cmd1 ~data:"");
+
+
(* 2. Frame command *)
+
print_endline "\n2. Frame (a=f):";
+
let cmd2 = K.Command.frame
+
~image_id ~format:`Rgba32 ~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only () in
+
print_escaped (K.Command.to_string cmd2 ~data:"");
+
+
(* 3. Put/display command *)
+
print_endline "\n3. Display/Put (a=p):";
+
let cmd3 = K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cell_x_offset:0
+
~cell_y_offset:0
+
~cursor:`Static ())
+
~quiet:`Errors_only () in
+
print_escaped (K.Command.to_string cmd3 ~data:"");
+
+
(* 4. Set root frame gap - IMPORTANT for animation! *)
+
print_endline "\n4. Set root frame gap (a=a, r=1, z=100):";
+
let cmd4 = K.Command.animate ~image_id (K.Animation.set_gap ~frame:1 ~gap_ms:100) in
+
print_escaped (K.Command.to_string cmd4 ~data:"");
+
+
(* 5. Animate - start *)
+
print_endline "\n5. Animate start (a=a, s=3, v=1):";
+
let cmd5 = K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run) in
+
print_escaped (K.Command.to_string cmd5 ~data:"");
+
+
(* 6. Animate - stop *)
+
print_endline "\n6. Animate stop (a=a, s=1):";
+
let cmd6 = K.Command.animate ~image_id (K.Animation.set_state `Stop) in
+
print_escaped (K.Command.to_string cmd6 ~data:"")
+108
example/tiny_anim.ml
···
+
(* Tiny animation test - no chunking needed *)
+
(* Uses 20x20 images which are ~1067 bytes base64 (well under 4096) *)
+
+
module K = Kitty_graphics
+
+
let solid_color_rgba ~width ~height ~r ~g ~b ~a =
+
let pixels = Bytes.create (width * height * 4) in
+
for i = 0 to (width * height) - 1 do
+
let idx = i * 4 in
+
Bytes.set pixels idx (Char.chr r);
+
Bytes.set pixels (idx + 1) (Char.chr g);
+
Bytes.set pixels (idx + 2) (Char.chr b);
+
Bytes.set pixels (idx + 3) (Char.chr a)
+
done;
+
Bytes.to_string pixels
+
+
let send cmd ~data =
+
print_string (K.Command.to_string cmd ~data);
+
flush stdout
+
+
let () =
+
(* Use 20x20 to avoid chunking: 20*20*4 = 1600 bytes, base64 ~2134 bytes *)
+
let width, height = 20, 20 in
+
let image_id = 999 in
+
+
(* Clear any existing images *)
+
send (K.Command.delete ~quiet:`Errors_only (`All_visible_and_free)) ~data:"";
+
+
(* Step 1: Transmit base frame (red) - matching Go's sequence *)
+
let red_frame = solid_color_rgba ~width ~height ~r:255 ~g:0 ~b:0 ~a:255 in
+
send
+
(K.Command.transmit
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~quiet:`Errors_only
+
())
+
~data:red_frame;
+
+
(* Step 2: Add frame (orange) with 100ms gap - like Go *)
+
let orange_frame = solid_color_rgba ~width ~height ~r:255 ~g:165 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:orange_frame;
+
+
(* Step 3: Add frame (yellow) *)
+
let yellow_frame = solid_color_rgba ~width ~height ~r:255 ~g:255 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:yellow_frame;
+
+
(* Step 4: Add frame (green) *)
+
let green_frame = solid_color_rgba ~width ~height ~r:0 ~g:255 ~b:0 ~a:255 in
+
send
+
(K.Command.frame
+
~image_id
+
~format:`Rgba32
+
~width ~height
+
~frame:(K.Frame.make ~gap_ms:100 ~composition:`Overwrite ())
+
~quiet:`Errors_only
+
())
+
~data:green_frame;
+
+
(* Step 5: Create placement - exactly like Go *)
+
send
+
(K.Command.display
+
~image_id
+
~placement:(K.Placement.make
+
~placement_id:1
+
~cell_x_offset:0
+
~cell_y_offset:0
+
~cursor:`Static
+
())
+
~quiet:`Errors_only
+
())
+
~data:"";
+
+
(* Step 6: Start animation - exactly like Go (NO root frame gap) *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state ~loops:1 `Run))
+
~data:"";
+
+
print_endline "";
+
print_endline "Tiny animation (20x20) - Red -> Orange -> Yellow -> Green";
+
print_endline "This uses no chunking. Press Enter to stop...";
+
flush stdout;
+
let _ = read_line () in
+
+
(* Stop animation *)
+
send
+
(K.Command.animate ~image_id (K.Animation.set_state `Stop))
+
~data:"";
+
+
(* Clean up *)
+
send (K.Command.delete ~quiet:`Errors_only (`All_visible_and_free)) ~data:"";
+
print_endline "Done."
+4
lib/dune
···
+
(library
+
(name kitty_graphics)
+
(public_name kitty_graphics)
+
(libraries base64))
+687
lib/kitty_graphics.ml
···
+
(* Kitty Terminal Graphics Protocol - Implementation *)
+
+
(* Polymorphic variant types *)
+
type format = [ `Rgba32 | `Rgb24 | `Png ]
+
type transmission = [ `Direct | `File | `Tempfile ]
+
type compression = [ `None | `Zlib ]
+
type quiet = [ `Noisy | `Errors_only | `Silent ]
+
type cursor = [ `Move | `Static ]
+
type composition = [ `Alpha_blend | `Overwrite ]
+
+
type delete =
+
[ `All_visible
+
| `All_visible_and_free
+
| `By_id of int * int option
+
| `By_id_and_free of int * int option
+
| `By_number of int * int option
+
| `By_number_and_free of int * int option
+
| `At_cursor
+
| `At_cursor_and_free
+
| `At_cell of int * int
+
| `At_cell_and_free of int * int
+
| `At_cell_z of int * int * int
+
| `At_cell_z_and_free of int * int * int
+
| `By_column of int
+
| `By_column_and_free of int
+
| `By_row of int
+
| `By_row_and_free of int
+
| `By_z_index of int
+
| `By_z_index_and_free of int
+
| `By_id_range of int * int
+
| `By_id_range_and_free of int * int
+
| `Frames
+
| `Frames_and_free ]
+
+
type animation_state = [ `Stop | `Loading | `Run ]
+
+
(* Modules re-export the types with conversion functions *)
+
module Format = struct
+
type t = format
+
+
let to_int : t -> int = function
+
| `Rgba32 -> 32
+
| `Rgb24 -> 24
+
| `Png -> 100
+
end
+
+
module Transmission = struct
+
type t = transmission
+
+
let to_char : t -> char = function
+
| `Direct -> 'd'
+
| `File -> 'f'
+
| `Tempfile -> 't'
+
end
+
+
module Compression = struct
+
type t = compression
+
+
let to_char : t -> char option = function
+
| `None -> None
+
| `Zlib -> Some 'z'
+
end
+
+
module Quiet = struct
+
type t = quiet
+
+
let to_int : t -> int = function
+
| `Noisy -> 0
+
| `Errors_only -> 1
+
| `Silent -> 2
+
end
+
+
module Cursor = struct
+
type t = cursor
+
+
let to_int : t -> int = function
+
| `Move -> 0
+
| `Static -> 1
+
end
+
+
module Composition = struct
+
type t = composition
+
+
let to_int : t -> int = function
+
| `Alpha_blend -> 0
+
| `Overwrite -> 1
+
end
+
+
module Delete = struct
+
type t = delete
+
end
+
+
module Placement = struct
+
type t = {
+
source_x : int option;
+
source_y : int option;
+
source_width : int option;
+
source_height : int option;
+
cell_x_offset : int option;
+
cell_y_offset : int option;
+
columns : int option;
+
rows : int option;
+
z_index : int option;
+
placement_id : int option;
+
cursor : cursor option;
+
unicode_placeholder : bool;
+
}
+
+
let empty =
+
{
+
source_x = None;
+
source_y = None;
+
source_width = None;
+
source_height = None;
+
cell_x_offset = None;
+
cell_y_offset = None;
+
columns = None;
+
rows = None;
+
z_index = None;
+
placement_id = None;
+
cursor = None;
+
unicode_placeholder = false;
+
}
+
+
let make ?source_x ?source_y ?source_width ?source_height ?cell_x_offset
+
?cell_y_offset ?columns ?rows ?z_index ?placement_id ?cursor
+
?(unicode_placeholder = false) () =
+
{
+
source_x;
+
source_y;
+
source_width;
+
source_height;
+
cell_x_offset;
+
cell_y_offset;
+
columns;
+
rows;
+
z_index;
+
placement_id;
+
cursor;
+
unicode_placeholder;
+
}
+
end
+
+
module Frame = struct
+
type t = {
+
x : int option;
+
y : int option;
+
base_frame : int option;
+
edit_frame : int option;
+
gap_ms : int option;
+
composition : composition option;
+
background_color : int32 option;
+
}
+
+
let empty =
+
{
+
x = None;
+
y = None;
+
base_frame = None;
+
edit_frame = None;
+
gap_ms = None;
+
composition = None;
+
background_color = None;
+
}
+
+
let make ?x ?y ?base_frame ?edit_frame ?gap_ms ?composition ?background_color
+
() =
+
{ x; y; base_frame; edit_frame; gap_ms; composition; background_color }
+
end
+
+
module Animation = struct
+
type state = animation_state
+
+
type t =
+
[ `Set_state of state * int option
+
| `Set_gap of int * int
+
| `Set_current of int ]
+
+
let set_state ?loops state = `Set_state (state, loops)
+
let set_gap ~frame ~gap_ms = `Set_gap (frame, gap_ms)
+
let set_current_frame frame = `Set_current frame
+
end
+
+
module Compose = struct
+
type t = {
+
source_frame : int;
+
dest_frame : int;
+
width : int option;
+
height : int option;
+
source_x : int option;
+
source_y : int option;
+
dest_x : int option;
+
dest_y : int option;
+
composition : composition option;
+
}
+
+
let make ~source_frame ~dest_frame ?width ?height ?source_x ?source_y ?dest_x
+
?dest_y ?composition () =
+
{
+
source_frame;
+
dest_frame;
+
width;
+
height;
+
source_x;
+
source_y;
+
dest_x;
+
dest_y;
+
composition;
+
}
+
end
+
+
module Command = struct
+
type action =
+
[ `Transmit
+
| `Transmit_and_display
+
| `Query
+
| `Display
+
| `Delete
+
| `Frame
+
| `Animate
+
| `Compose ]
+
+
type t = {
+
action : action;
+
format : format option;
+
transmission : transmission option;
+
compression : compression option;
+
width : int option;
+
height : int option;
+
size : int option;
+
offset : int option;
+
quiet : quiet option;
+
image_id : int option;
+
image_number : int option;
+
placement : Placement.t option;
+
delete : delete option;
+
frame : Frame.t option;
+
animation : Animation.t option;
+
compose : Compose.t option;
+
}
+
+
let make action =
+
{
+
action;
+
format = None;
+
transmission = None;
+
compression = None;
+
width = None;
+
height = None;
+
size = None;
+
offset = None;
+
quiet = None;
+
image_id = None;
+
image_number = None;
+
placement = None;
+
delete = None;
+
frame = None;
+
animation = None;
+
compose = None;
+
}
+
+
let transmit ?image_id ?image_number ?format ?transmission ?compression ?width
+
?height ?size ?offset ?quiet () =
+
{
+
(make `Transmit) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
size;
+
offset;
+
quiet;
+
}
+
+
let transmit_and_display ?image_id ?image_number ?format ?transmission
+
?compression ?width ?height ?size ?offset ?quiet ?placement () =
+
{
+
(make `Transmit_and_display) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
size;
+
offset;
+
quiet;
+
placement;
+
}
+
+
let query ?format ?transmission ?width ?height ?quiet () =
+
{ (make `Query) with format; transmission; width; height; quiet }
+
+
let display ?image_id ?image_number ?placement ?quiet () =
+
{ (make `Display) with image_id; image_number; placement; quiet }
+
+
let delete ?quiet del = { (make `Delete) with quiet; delete = Some del }
+
+
let frame ?image_id ?image_number ?format ?transmission ?compression ?width
+
?height ?quiet ~frame () =
+
{
+
(make `Frame) with
+
image_id;
+
image_number;
+
format;
+
transmission;
+
compression;
+
width;
+
height;
+
quiet;
+
frame = Some frame;
+
}
+
+
let animate ?image_id ?image_number ?quiet anim =
+
{ (make `Animate) with image_id; image_number; quiet; animation = Some anim }
+
+
let compose ?image_id ?image_number ?quiet comp =
+
{ (make `Compose) with image_id; image_number; quiet; compose = Some comp }
+
+
(* Serialization helpers *)
+
let apc_start = "\027_G"
+
let apc_end = "\027\\"
+
+
(* Key-value writer with separator handling *)
+
type kv_writer = { mutable first : bool; buf : Buffer.t }
+
+
let kv_writer buf = { first = true; buf }
+
+
let kv w key value =
+
if not w.first then Buffer.add_char w.buf ',';
+
w.first <- false;
+
Buffer.add_char w.buf key;
+
Buffer.add_char w.buf '=';
+
Buffer.add_string w.buf value
+
+
let kv_int w key value = kv w key (string_of_int value)
+
let kv_int32 w key value = kv w key (Int32.to_string value)
+
let kv_char w key value = kv w key (String.make 1 value)
+
+
(* Conditional writers using Option.iter *)
+
let kv_int_opt w key = Option.iter (kv_int w key)
+
let kv_int32_opt w key = Option.iter (kv_int32 w key)
+
+
let kv_int_if w key ~default opt =
+
Option.iter (fun v -> if v <> default then kv_int w key v) opt
+
+
let action_char : action -> char = function
+
| `Transmit -> 't'
+
| `Transmit_and_display -> 'T'
+
| `Query -> 'q'
+
| `Display -> 'p'
+
| `Delete -> 'd'
+
| `Frame -> 'f'
+
| `Animate -> 'a'
+
| `Compose -> 'c'
+
+
let delete_char : delete -> char = function
+
| `All_visible -> 'a'
+
| `All_visible_and_free -> 'A'
+
| `By_id _ -> 'i'
+
| `By_id_and_free _ -> 'I'
+
| `By_number _ -> 'n'
+
| `By_number_and_free _ -> 'N'
+
| `At_cursor -> 'c'
+
| `At_cursor_and_free -> 'C'
+
| `At_cell _ -> 'p'
+
| `At_cell_and_free _ -> 'P'
+
| `At_cell_z _ -> 'q'
+
| `At_cell_z_and_free _ -> 'Q'
+
| `By_column _ -> 'x'
+
| `By_column_and_free _ -> 'X'
+
| `By_row _ -> 'y'
+
| `By_row_and_free _ -> 'Y'
+
| `By_z_index _ -> 'z'
+
| `By_z_index_and_free _ -> 'Z'
+
| `By_id_range _ -> 'r'
+
| `By_id_range_and_free _ -> 'R'
+
| `Frames -> 'f'
+
| `Frames_and_free -> 'F'
+
+
let write_placement w (p : Placement.t) =
+
kv_int_opt w 'x' p.source_x;
+
kv_int_opt w 'y' p.source_y;
+
kv_int_opt w 'w' p.source_width;
+
kv_int_opt w 'h' p.source_height;
+
kv_int_opt w 'X' p.cell_x_offset;
+
kv_int_opt w 'Y' p.cell_y_offset;
+
kv_int_opt w 'c' p.columns;
+
kv_int_opt w 'r' p.rows;
+
kv_int_opt w 'z' p.z_index;
+
kv_int_opt w 'p' p.placement_id;
+
p.cursor |> Option.iter (fun c -> kv_int_if w 'C' ~default:0 (Some (Cursor.to_int c)));
+
if p.unicode_placeholder then kv_int w 'U' 1
+
+
let write_delete w (d : delete) =
+
kv_char w 'd' (delete_char d);
+
match d with
+
| `By_id (id, pid) | `By_id_and_free (id, pid) ->
+
kv_int w 'i' id;
+
kv_int_opt w 'p' pid
+
| `By_number (n, pid) | `By_number_and_free (n, pid) ->
+
kv_int w 'I' n;
+
kv_int_opt w 'p' pid
+
| `At_cell (x, y) | `At_cell_and_free (x, y) ->
+
kv_int w 'x' x;
+
kv_int w 'y' y
+
| `At_cell_z (x, y, z) | `At_cell_z_and_free (x, y, z) ->
+
kv_int w 'x' x;
+
kv_int w 'y' y;
+
kv_int w 'z' z
+
| `By_column c | `By_column_and_free c -> kv_int w 'x' c
+
| `By_row r | `By_row_and_free r -> kv_int w 'y' r
+
| `By_z_index z | `By_z_index_and_free z -> kv_int w 'z' z
+
| `By_id_range (min_id, max_id) | `By_id_range_and_free (min_id, max_id) ->
+
kv_int w 'x' min_id;
+
kv_int w 'y' max_id
+
| `All_visible | `All_visible_and_free | `At_cursor | `At_cursor_and_free
+
| `Frames | `Frames_and_free ->
+
()
+
+
let write_frame w (f : Frame.t) =
+
kv_int_opt w 'x' f.x;
+
kv_int_opt w 'y' f.y;
+
kv_int_opt w 'c' f.base_frame;
+
kv_int_opt w 'r' f.edit_frame;
+
kv_int_opt w 'z' f.gap_ms;
+
f.composition
+
|> Option.iter (fun c -> kv_int_if w 'X' ~default:0 (Some (Composition.to_int c)));
+
kv_int32_opt w 'Y' f.background_color
+
+
let write_animation w : Animation.t -> unit = function
+
| `Set_state (state, loops) ->
+
let s = match state with `Stop -> 1 | `Loading -> 2 | `Run -> 3 in
+
kv_int w 's' s;
+
kv_int_opt w 'v' loops
+
| `Set_gap (frame, gap_ms) ->
+
kv_int w 'r' frame;
+
kv_int w 'z' gap_ms
+
| `Set_current frame -> kv_int w 'c' frame
+
+
let write_compose w (c : Compose.t) =
+
kv_int w 'r' c.source_frame;
+
kv_int w 'c' c.dest_frame;
+
kv_int_opt w 'w' c.width;
+
kv_int_opt w 'h' c.height;
+
kv_int_opt w 'x' c.dest_x;
+
kv_int_opt w 'y' c.dest_y;
+
kv_int_opt w 'X' c.source_x;
+
kv_int_opt w 'Y' c.source_y;
+
c.composition
+
|> Option.iter (fun comp -> kv_int_if w 'C' ~default:0 (Some (Composition.to_int comp)))
+
+
let write_control_data buf cmd =
+
let w = kv_writer buf in
+
(* Action *)
+
kv_char w 'a' (action_char cmd.action);
+
(* Quiet - only if non-default *)
+
cmd.quiet |> Option.iter (fun q -> kv_int_if w 'q' ~default:0 (Some (Quiet.to_int q)));
+
(* Format *)
+
cmd.format |> Option.iter (fun f -> kv_int w 'f' (Format.to_int f));
+
(* Transmission - only for transmit/frame actions, always include t=d for compatibility *)
+
(match cmd.action with
+
| `Transmit | `Transmit_and_display | `Frame ->
+
(match cmd.transmission with
+
| Some t -> kv_char w 't' (Transmission.to_char t)
+
| None -> kv_char w 't' 'd')
+
| _ -> ());
+
(* Compression *)
+
cmd.compression |> Option.iter (fun c -> Compression.to_char c |> Option.iter (kv_char w 'o'));
+
(* Dimensions *)
+
kv_int_opt w 's' cmd.width;
+
kv_int_opt w 'v' cmd.height;
+
(* File size/offset *)
+
kv_int_opt w 'S' cmd.size;
+
kv_int_opt w 'O' cmd.offset;
+
(* Image ID/number *)
+
kv_int_opt w 'i' cmd.image_id;
+
kv_int_opt w 'I' cmd.image_number;
+
(* Complex options *)
+
cmd.placement |> Option.iter (write_placement w);
+
cmd.delete |> Option.iter (write_delete w);
+
cmd.frame |> Option.iter (write_frame w);
+
cmd.animation |> Option.iter (write_animation w);
+
cmd.compose |> Option.iter (write_compose w);
+
w
+
+
(* Use large chunk size to avoid chunking - Kitty animation doesn't handle chunks well *)
+
let chunk_size = 1024 * 1024 (* 1MB - effectively no chunking *)
+
+
let write buf cmd ~data =
+
Buffer.add_string buf apc_start;
+
let w = write_control_data buf cmd in
+
if String.length data > 0 then begin
+
let encoded = Base64.encode_string data in
+
let len = String.length encoded in
+
if len <= chunk_size then (
+
Buffer.add_char buf ';';
+
Buffer.add_string buf encoded;
+
Buffer.add_string buf apc_end)
+
else begin
+
(* Multiple chunks *)
+
let rec write_chunks pos first =
+
if pos < len then begin
+
let remaining = len - pos in
+
let this_chunk = min chunk_size remaining in
+
let is_last = pos + this_chunk >= len in
+
if first then (
+
kv_int w 'm' 1;
+
Buffer.add_char buf ';';
+
Buffer.add_substring buf encoded pos this_chunk;
+
Buffer.add_string buf apc_end)
+
else (
+
Buffer.add_string buf apc_start;
+
Buffer.add_string buf (if is_last then "m=0" else "m=1");
+
Buffer.add_char buf ';';
+
Buffer.add_substring buf encoded pos this_chunk;
+
Buffer.add_string buf apc_end);
+
write_chunks (pos + this_chunk) false
+
end
+
in
+
write_chunks 0 true
+
end
+
end
+
else Buffer.add_string buf apc_end
+
+
let to_string cmd ~data =
+
let buf = Buffer.create 1024 in
+
write buf cmd ~data;
+
Buffer.contents buf
+
end
+
+
module Response = struct
+
type t = {
+
message : string;
+
image_id : int option;
+
image_number : int option;
+
placement_id : int option;
+
}
+
+
let is_ok t = t.message = "OK"
+
let message t = t.message
+
+
let error_code t =
+
if is_ok t then None
+
else String.index_opt t.message ':' |> Option.fold ~none:(Some t.message) ~some:(fun i -> Some (String.sub t.message 0 i))
+
+
let image_id t = t.image_id
+
let image_number t = t.image_number
+
let placement_id t = t.placement_id
+
+
let parse s =
+
let ( let* ) = Option.bind in
+
let esc = '\027' in
+
let len = String.length s in
+
let* () = if len >= 5 && s.[0] = esc && s.[1] = '_' && s.[2] = 'G' then Some () else None in
+
let* semi_pos = String.index_from_opt s 3 ';' in
+
let rec find_end pos =
+
if pos + 1 < len && s.[pos] = esc && s.[pos + 1] = '\\' then Some pos
+
else if pos + 1 < len then find_end (pos + 1)
+
else None
+
in
+
let* end_pos = find_end (semi_pos + 1) in
+
let keys_str = String.sub s 3 (semi_pos - 3) in
+
let message = String.sub s (semi_pos + 1) (end_pos - semi_pos - 1) in
+
let parse_kv part =
+
if String.length part >= 3 && part.[1] = '=' then
+
Some (part.[0], String.sub part 2 (String.length part - 2))
+
else None
+
in
+
let keys = String.split_on_char ',' keys_str |> List.filter_map parse_kv in
+
let find_int key = List.assoc_opt key keys |> Fun.flip Option.bind int_of_string_opt in
+
Some
+
{
+
message;
+
image_id = find_int 'i';
+
image_number = find_int 'I';
+
placement_id = find_int 'p';
+
}
+
end
+
+
module Unicode_placeholder = struct
+
let placeholder_char = Uchar.of_int 0x10EEEE
+
+
let diacritics =
+
[|
+
0x0305; 0x030D; 0x030E; 0x0310; 0x0312; 0x033D; 0x033E; 0x033F;
+
0x0346; 0x034A; 0x034B; 0x034C; 0x0350; 0x0351; 0x0352; 0x0357;
+
0x035B; 0x0363; 0x0364; 0x0365; 0x0366; 0x0367; 0x0368; 0x0369;
+
0x036A; 0x036B; 0x036C; 0x036D; 0x036E; 0x036F; 0x0483; 0x0484;
+
0x0485; 0x0486; 0x0487; 0x0592; 0x0593; 0x0594; 0x0595; 0x0597;
+
0x0598; 0x0599; 0x059C; 0x059D; 0x059E; 0x059F; 0x05A0; 0x05A1;
+
0x05A8; 0x05A9; 0x05AB; 0x05AC; 0x05AF; 0x05C4; 0x0610; 0x0611;
+
0x0612; 0x0613; 0x0614; 0x0615; 0x0616; 0x0617; 0x0657; 0x0658;
+
0x0659; 0x065A; 0x065B; 0x065D; 0x065E; 0x06D6; 0x06D7; 0x06D8;
+
0x06D9; 0x06DA; 0x06DB; 0x06DC; 0x06DF; 0x06E0; 0x06E1; 0x06E2;
+
0x06E4; 0x06E7; 0x06E8; 0x06EB; 0x06EC; 0x0730; 0x0732; 0x0733;
+
0x0735; 0x0736; 0x073A; 0x073D; 0x073F; 0x0740; 0x0741; 0x0743;
+
0x0745; 0x0747; 0x0749; 0x074A; 0x07EB; 0x07EC; 0x07ED; 0x07EE;
+
0x07EF; 0x07F0; 0x07F1; 0x07F3; 0x0816; 0x0817; 0x0818; 0x0819;
+
0x081B; 0x081C; 0x081D; 0x081E; 0x081F; 0x0820; 0x0821; 0x0822;
+
0x0823; 0x0825; 0x0826; 0x0827; 0x0829; 0x082A; 0x082B; 0x082C;
+
0x082D; 0x0951; 0x0953; 0x0954; 0x0F82; 0x0F83; 0x0F86; 0x0F87;
+
0x135D; 0x135E; 0x135F; 0x17DD; 0x193A; 0x1A17; 0x1A75; 0x1A76;
+
0x1A77; 0x1A78; 0x1A79; 0x1A7A; 0x1A7B; 0x1A7C; 0x1B6B; 0x1B6D;
+
0x1B6E; 0x1B6F; 0x1B70; 0x1B71; 0x1B72; 0x1B73; 0x1CD0; 0x1CD1;
+
0x1CD2; 0x1CDA; 0x1CDB; 0x1CE0; 0x1DC0; 0x1DC1; 0x1DC3; 0x1DC4;
+
0x1DC5; 0x1DC6; 0x1DC7; 0x1DC8; 0x1DC9; 0x1DCB; 0x1DCC; 0x1DD1;
+
0x1DD2; 0x1DD3; 0x1DD4; 0x1DD5; 0x1DD6; 0x1DD7; 0x1DD8; 0x1DD9;
+
0x1DDA; 0x1DDB; 0x1DDC; 0x1DDD; 0x1DDE; 0x1DDF; 0x1DE0; 0x1DE1;
+
0x1DE2; 0x1DE3; 0x1DE4; 0x1DE5; 0x1DE6; 0x1DFE; 0x20D0; 0x20D1;
+
0x20D4; 0x20D5; 0x20D6; 0x20D7; 0x20DB; 0x20DC; 0x20E1; 0x20E7;
+
0x20E9; 0x20F0; 0xA66F; 0xA67C; 0xA67D; 0xA6F0; 0xA6F1; 0xA8E0;
+
0xA8E1; 0xA8E2; 0xA8E3; 0xA8E4; 0xA8E5; 0xA8E6; 0xA8E7; 0xA8E8;
+
0xA8E9; 0xA8EA; 0xA8EB; 0xA8EC; 0xA8ED; 0xA8EE; 0xA8EF; 0xA8F0;
+
0xA8F1; 0xAAB0; 0xAAB2; 0xAAB3; 0xAAB7; 0xAAB8; 0xAABE; 0xAABF;
+
0xAAC1; 0xFE20; 0xFE21; 0xFE22; 0xFE23; 0xFE24; 0xFE25; 0xFE26;
+
0x10A0F; 0x10A38; 0x1D185; 0x1D186; 0x1D187; 0x1D188; 0x1D189;
+
0x1D1AA; 0x1D1AB; 0x1D1AC; 0x1D1AD; 0x1D242; 0x1D243; 0x1D244;
+
|]
+
+
let diacritic n =
+
Uchar.of_int diacritics.(n mod Array.length diacritics)
+
+
let row_diacritic = diacritic
+
let column_diacritic = diacritic
+
let id_high_byte_diacritic = diacritic
+
+
let add_uchar buf u =
+
let code = Uchar.to_int u in
+
let put = Buffer.add_char buf in
+
if code < 0x80 then put (Char.chr code)
+
else if code < 0x800 then (
+
put (Char.chr (0xC0 lor (code lsr 6)));
+
put (Char.chr (0x80 lor (code land 0x3F))))
+
else if code < 0x10000 then (
+
put (Char.chr (0xE0 lor (code lsr 12)));
+
put (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
+
put (Char.chr (0x80 lor (code land 0x3F))))
+
else (
+
put (Char.chr (0xF0 lor (code lsr 18)));
+
put (Char.chr (0x80 lor ((code lsr 12) land 0x3F)));
+
put (Char.chr (0x80 lor ((code lsr 6) land 0x3F)));
+
put (Char.chr (0x80 lor (code land 0x3F))))
+
+
let write buf ~image_id ?placement_id ~rows ~cols () =
+
(* Set foreground color *)
+
Printf.bprintf buf "\027[38;2;%d;%d;%dm"
+
((image_id lsr 16) land 0xFF)
+
((image_id lsr 8) land 0xFF)
+
(image_id land 0xFF);
+
(* Optional placement ID in underline color *)
+
placement_id
+
|> Option.iter (fun pid ->
+
Printf.bprintf buf "\027[58;2;%d;%d;%dm"
+
((pid lsr 16) land 0xFF)
+
((pid lsr 8) land 0xFF)
+
(pid land 0xFF));
+
(* High byte diacritic *)
+
let high_byte = (image_id lsr 24) land 0xFF in
+
let high_diac = if high_byte > 0 then Some (id_high_byte_diacritic high_byte) else None in
+
(* Write grid *)
+
for row = 0 to rows - 1 do
+
for col = 0 to cols - 1 do
+
add_uchar buf placeholder_char;
+
add_uchar buf (row_diacritic row);
+
add_uchar buf (column_diacritic col);
+
high_diac |> Option.iter (add_uchar buf)
+
done;
+
if row < rows - 1 then Buffer.add_string buf "\n\r"
+
done;
+
(* Reset colors *)
+
Buffer.add_string buf "\027[39m";
+
if Option.is_some placement_id then Buffer.add_string buf "\027[59m"
+
end
+
+
module Detect = struct
+
let make_query () =
+
let cmd = Command.query ~format:`Rgb24 ~transmission:`Direct ~width:1 ~height:1 () in
+
Command.to_string cmd ~data:"\x00\x00\x00"
+
+
let supports_graphics response ~da1_received =
+
response |> Option.map Response.is_ok |> Option.value ~default:(not da1_received)
+
end
+402
lib/kitty_graphics.mli
···
+
(** Kitty Terminal Graphics Protocol
+
+
This library implements the Kitty terminal graphics protocol, allowing
+
OCaml programs to display images in terminals that support the protocol
+
(Kitty, WezTerm, Konsole, Ghostty, etc.).
+
+
The protocol uses APC (Application Programming Command) escape sequences
+
to transmit and display pixel graphics. Images can be transmitted as raw
+
RGB/RGBA data or PNG, and displayed at specific positions with various
+
placement options.
+
+
{2 Basic Usage}
+
+
{[
+
(* Display a PNG image *)
+
let png_data = read_file "image.png" in
+
let cmd = Kitty_graphics.Command.transmit_and_display ~format:`Png () in
+
let buf = Buffer.create 1024 in
+
Kitty_graphics.Command.write buf cmd ~data:png_data;
+
print_string (Buffer.contents buf)
+
]}
+
+
{2 Protocol Reference}
+
+
See {{:https://sw.kovidgoyal.net/kitty/graphics-protocol/} Kitty Graphics Protocol}
+
for the full specification. *)
+
+
(** {1 Polymorphic Variant Types} *)
+
+
type format = [ `Rgba32 | `Rgb24 | `Png ]
+
(** Image data formats. [`Rgba32] is 32-bit RGBA (4 bytes per pixel),
+
[`Rgb24] is 24-bit RGB (3 bytes per pixel), [`Png] is PNG encoded data. *)
+
+
type transmission = [ `Direct | `File | `Tempfile ]
+
(** Transmission methods. [`Direct] sends data inline, [`File] reads from a path,
+
[`Tempfile] reads from a temp file that the terminal deletes after reading. *)
+
+
type compression = [ `None | `Zlib ]
+
(** Compression options. [`None] for raw data, [`Zlib] for RFC 1950 compression. *)
+
+
type quiet = [ `Noisy | `Errors_only | `Silent ]
+
(** Response suppression. [`Noisy] sends all responses (default),
+
[`Errors_only] suppresses OK responses, [`Silent] suppresses all. *)
+
+
type cursor = [ `Move | `Static ]
+
(** Cursor movement after displaying. [`Move] advances cursor (default),
+
[`Static] keeps cursor in place. *)
+
+
type composition = [ `Alpha_blend | `Overwrite ]
+
(** Composition modes. [`Alpha_blend] for full blending (default),
+
[`Overwrite] for simple pixel replacement. *)
+
+
type delete =
+
[ `All_visible
+
| `All_visible_and_free
+
| `By_id of int * int option
+
| `By_id_and_free of int * int option
+
| `By_number of int * int option
+
| `By_number_and_free of int * int option
+
| `At_cursor
+
| `At_cursor_and_free
+
| `At_cell of int * int
+
| `At_cell_and_free of int * int
+
| `At_cell_z of int * int * int
+
| `At_cell_z_and_free of int * int * int
+
| `By_column of int
+
| `By_column_and_free of int
+
| `By_row of int
+
| `By_row_and_free of int
+
| `By_z_index of int
+
| `By_z_index_and_free of int
+
| `By_id_range of int * int
+
| `By_id_range_and_free of int * int
+
| `Frames
+
| `Frames_and_free ]
+
(** Delete target specification. Each variant has two forms: one that only
+
removes placements (e.g., [`All_visible]) and one that also frees the
+
image data (e.g., [`All_visible_and_free]). Tuple variants contain
+
(image_id, optional_placement_id) or (x, y) coordinates. *)
+
+
type animation_state = [ `Stop | `Loading | `Run ]
+
(** Animation playback state. [`Stop] halts animation, [`Loading] runs but
+
waits for new frames at end, [`Run] runs normally and loops. *)
+
+
(** {1 Type Modules} *)
+
+
module Format : sig
+
type t = format
+
+
val to_int : t -> int
+
(** Convert to protocol integer value (32, 24, or 100). *)
+
end
+
+
module Transmission : sig
+
type t = transmission
+
+
val to_char : t -> char
+
(** Convert to protocol character ('d', 'f', or 't'). *)
+
end
+
+
module Compression : sig
+
type t = compression
+
+
val to_char : t -> char option
+
(** Convert to protocol character ([None] or [Some 'z']). *)
+
end
+
+
module Quiet : sig
+
type t = quiet
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0, 1, or 2). *)
+
end
+
+
module Cursor : sig
+
type t = cursor
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0 or 1). *)
+
end
+
+
module Composition : sig
+
type t = composition
+
+
val to_int : t -> int
+
(** Convert to protocol integer (0 or 1). *)
+
end
+
+
module Delete : sig
+
type t = delete
+
end
+
+
(** {1 Placement Options} *)
+
+
module Placement : sig
+
type t
+
(** Placement configuration. *)
+
+
val make :
+
?source_x:int ->
+
?source_y:int ->
+
?source_width:int ->
+
?source_height:int ->
+
?cell_x_offset:int ->
+
?cell_y_offset:int ->
+
?columns:int ->
+
?rows:int ->
+
?z_index:int ->
+
?placement_id:int ->
+
?cursor:cursor ->
+
?unicode_placeholder:bool ->
+
unit ->
+
t
+
(** Create a placement configuration.
+
+
@param source_x Left edge of source rectangle in pixels (default 0)
+
@param source_y Top edge of source rectangle in pixels (default 0)
+
@param source_width Width of source rectangle (default: full width)
+
@param source_height Height of source rectangle (default: full height)
+
@param cell_x_offset X offset within the first cell in pixels
+
@param cell_y_offset Y offset within the first cell in pixels
+
@param columns Number of columns to display over (scales image)
+
@param rows Number of rows to display over (scales image)
+
@param z_index Stacking order (negative = under text)
+
@param placement_id Unique ID for this placement
+
@param cursor Cursor movement policy after display
+
@param unicode_placeholder Create virtual placement for Unicode mode *)
+
+
val empty : t
+
(** Empty placement with all defaults. *)
+
end
+
+
(** {1 Animation} *)
+
+
module Frame : sig
+
type t
+
(** Animation frame configuration. *)
+
+
val make :
+
?x:int ->
+
?y:int ->
+
?base_frame:int ->
+
?edit_frame:int ->
+
?gap_ms:int ->
+
?composition:composition ->
+
?background_color:int32 ->
+
unit ->
+
t
+
(** Create a frame specification.
+
+
@param x Left edge where frame data is placed (pixels)
+
@param y Top edge where frame data is placed (pixels)
+
@param base_frame 1-based frame number to use as background canvas
+
@param edit_frame 1-based frame number to edit (0 = new frame)
+
@param gap_ms Delay before next frame in milliseconds
+
@param composition How to blend pixels onto the canvas
+
@param background_color 32-bit RGBA background when no base frame *)
+
+
val empty : t
+
(** Empty frame spec with defaults. *)
+
end
+
+
module Animation : sig
+
type state = animation_state
+
+
type t =
+
[ `Set_state of state * int option
+
| `Set_gap of int * int
+
| `Set_current of int ]
+
(** Animation control operations. *)
+
+
val set_state : ?loops:int -> state -> t
+
(** Set animation state.
+
@param loops Number of loops: 0 = ignored, 1 = infinite, n = n-1 loops *)
+
+
val set_gap : frame:int -> gap_ms:int -> t
+
(** Set the gap (delay) for a specific frame.
+
@param frame 1-based frame number
+
@param gap_ms Delay in milliseconds (negative = gapless) *)
+
+
val set_current_frame : int -> t
+
(** Make a specific frame (1-based) the current displayed frame. *)
+
end
+
+
module Compose : sig
+
type t
+
(** Composition operation. *)
+
+
val make :
+
source_frame:int ->
+
dest_frame:int ->
+
?width:int ->
+
?height:int ->
+
?source_x:int ->
+
?source_y:int ->
+
?dest_x:int ->
+
?dest_y:int ->
+
?composition:composition ->
+
unit ->
+
t
+
(** Compose a rectangle from one frame onto another. *)
+
end
+
+
(** {1 Commands} *)
+
+
module Command : sig
+
type t
+
(** A graphics protocol command. *)
+
+
(** {2 Image Transmission} *)
+
+
val transmit :
+
?image_id:int ->
+
?image_number:int ->
+
?format:format ->
+
?transmission:transmission ->
+
?compression:compression ->
+
?width:int ->
+
?height:int ->
+
?size:int ->
+
?offset:int ->
+
?quiet:quiet ->
+
unit ->
+
t
+
(** Transmit image data without displaying. *)
+
+
val transmit_and_display :
+
?image_id:int ->
+
?image_number:int ->
+
?format:format ->
+
?transmission:transmission ->
+
?compression:compression ->
+
?width:int ->
+
?height:int ->
+
?size:int ->
+
?offset:int ->
+
?quiet:quiet ->
+
?placement:Placement.t ->
+
unit ->
+
t
+
(** Transmit image data and display it immediately. *)
+
+
val query :
+
?format:format ->
+
?transmission:transmission ->
+
?width:int ->
+
?height:int ->
+
?quiet:quiet ->
+
unit ->
+
t
+
(** Query terminal support without storing the image. *)
+
+
(** {2 Display} *)
+
+
val display :
+
?image_id:int ->
+
?image_number:int ->
+
?placement:Placement.t ->
+
?quiet:quiet ->
+
unit ->
+
t
+
(** Display a previously transmitted image. *)
+
+
(** {2 Deletion} *)
+
+
val delete : ?quiet:quiet -> delete -> t
+
(** Delete images or placements. *)
+
+
(** {2 Animation} *)
+
+
val frame :
+
?image_id:int ->
+
?image_number:int ->
+
?format:format ->
+
?transmission:transmission ->
+
?compression:compression ->
+
?width:int ->
+
?height:int ->
+
?quiet:quiet ->
+
frame:Frame.t ->
+
unit ->
+
t
+
(** Transmit animation frame data. *)
+
+
val animate : ?image_id:int -> ?image_number:int -> ?quiet:quiet -> Animation.t -> t
+
(** Control animation playback. *)
+
+
val compose : ?image_id:int -> ?image_number:int -> ?quiet:quiet -> Compose.t -> t
+
(** Compose animation frames. *)
+
+
(** {2 Output} *)
+
+
val write : Buffer.t -> t -> data:string -> unit
+
(** Write the command to a buffer. *)
+
+
val to_string : t -> data:string -> string
+
(** Convert command to a string. *)
+
end
+
+
(** {1 Response Parsing} *)
+
+
module Response : sig
+
type t
+
(** A parsed terminal response. *)
+
+
val parse : string -> t option
+
(** Parse a response from terminal output. *)
+
+
val is_ok : t -> bool
+
(** Check if the response indicates success. *)
+
+
val message : t -> string
+
(** Get the response message. *)
+
+
val error_code : t -> string option
+
(** Extract the error code if this is an error response. *)
+
+
val image_id : t -> int option
+
(** Get the image ID from the response. *)
+
+
val image_number : t -> int option
+
(** Get the image number from the response. *)
+
+
val placement_id : t -> int option
+
(** Get the placement ID from the response. *)
+
end
+
+
(** {1 Unicode Placeholders} *)
+
+
module Unicode_placeholder : sig
+
val placeholder_char : Uchar.t
+
(** The Unicode placeholder character U+10EEEE. *)
+
+
val write :
+
Buffer.t ->
+
image_id:int ->
+
?placement_id:int ->
+
rows:int ->
+
cols:int ->
+
unit ->
+
unit
+
(** Write placeholder characters to a buffer. *)
+
+
val row_diacritic : int -> Uchar.t
+
(** Get the combining diacritic for a row number (0-based). *)
+
+
val column_diacritic : int -> Uchar.t
+
(** Get the combining diacritic for a column number (0-based). *)
+
+
val id_high_byte_diacritic : int -> Uchar.t
+
(** Get the diacritic for the high byte of a 32-bit image ID. *)
+
end
+
+
(** {1 Terminal Detection} *)
+
+
module Detect : sig
+
val make_query : unit -> string
+
(** Generate a query command to test graphics support. *)
+
+
val supports_graphics : Response.t option -> da1_received:bool -> bool
+
(** Determine if graphics are supported based on query results. *)
+
end