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 6type action = 7 [ `Transmit 8 | `Transmit_and_display 9 | `Query 10 | `Display 11 | `Delete 12 | `Frame 13 | `Animate 14 | `Compose ] 15 16type t = { 17 action : action; 18 format : Kgp_format.t option; 19 transmission : Kgp_transmission.t option; 20 compression : Kgp_compression.t option; 21 width : int option; 22 height : int option; 23 size : int option; 24 offset : int option; 25 quiet : Kgp_quiet.t option; 26 image_id : int option; 27 image_number : int option; 28 placement : Kgp_placement.t option; 29 delete : Kgp_delete.t option; 30 delete_free : bool; 31 frame : Kgp_frame.t option; 32 animation : Kgp_animation.t option; 33 compose : Kgp_compose.t option; 34} 35 36let make action = 37 { 38 action; 39 format = None; 40 transmission = None; 41 compression = None; 42 width = None; 43 height = None; 44 size = None; 45 offset = None; 46 quiet = None; 47 image_id = None; 48 image_number = None; 49 placement = None; 50 delete = None; 51 delete_free = false; 52 frame = None; 53 animation = None; 54 compose = None; 55 } 56 57let transmit ?image_id ?image_number ?format ?transmission ?compression ?width 58 ?height ?size ?offset ?quiet () = 59 { 60 (make `Transmit) with 61 image_id; 62 image_number; 63 format; 64 transmission; 65 compression; 66 width; 67 height; 68 size; 69 offset; 70 quiet; 71 } 72 73let transmit_and_display ?image_id ?image_number ?format ?transmission 74 ?compression ?width ?height ?size ?offset ?quiet ?placement () = 75 { 76 (make `Transmit_and_display) with 77 image_id; 78 image_number; 79 format; 80 transmission; 81 compression; 82 width; 83 height; 84 size; 85 offset; 86 quiet; 87 placement; 88 } 89 90let query ?format ?transmission ?width ?height ?quiet () = 91 { (make `Query) with format; transmission; width; height; quiet } 92 93let display ?image_id ?image_number ?placement ?quiet () = 94 { (make `Display) with image_id; image_number; placement; quiet } 95 96let delete ?(free = false) ?quiet del = 97 { (make `Delete) with quiet; delete = Some del; delete_free = free } 98 99let frame ?image_id ?image_number ?format ?transmission ?compression ?width 100 ?height ?quiet ~frame () = 101 { 102 (make `Frame) with 103 image_id; 104 image_number; 105 format; 106 transmission; 107 compression; 108 width; 109 height; 110 quiet; 111 frame = Some frame; 112 } 113 114let animate ?image_id ?image_number ?quiet anim = 115 { (make `Animate) with image_id; image_number; quiet; animation = Some anim } 116 117let compose ?image_id ?image_number ?quiet comp = 118 { (make `Compose) with image_id; image_number; quiet; compose = Some comp } 119 120(* Serialization helpers *) 121let apc_start = "\027_G" 122let apc_end = "\027\\" 123 124(* Key-value writer with separator handling *) 125type kv_writer = { mutable first : bool; buf : Buffer.t } 126 127let kv_writer buf = { first = true; buf } 128 129let kv w key value = 130 if not w.first then Buffer.add_char w.buf ','; 131 w.first <- false; 132 Buffer.add_char w.buf key; 133 Buffer.add_char w.buf '='; 134 Buffer.add_string w.buf value 135 136let kv_int w key value = kv w key (string_of_int value) 137let kv_int32 w key value = kv w key (Int32.to_string value) 138let kv_char w key value = kv w key (String.make 1 value) 139 140(* Conditional writers using Option.iter *) 141let kv_int_opt w key = Option.iter (kv_int w key) 142let kv_int32_opt w key = Option.iter (kv_int32 w key) 143 144let kv_int_if w key ~default opt = 145 Option.iter (fun v -> if v <> default then kv_int w key v) opt 146 147let action_char : action -> char = function 148 | `Transmit -> 't' 149 | `Transmit_and_display -> 'T' 150 | `Query -> 'q' 151 | `Display -> 'p' 152 | `Delete -> 'd' 153 | `Frame -> 'f' 154 | `Animate -> 'a' 155 | `Compose -> 'c' 156 157let write_placement w (p : Kgp_placement.t) = 158 kv_int_opt w 'x' (Kgp_placement.source_x p); 159 kv_int_opt w 'y' (Kgp_placement.source_y p); 160 kv_int_opt w 'w' (Kgp_placement.source_width p); 161 kv_int_opt w 'h' (Kgp_placement.source_height p); 162 kv_int_opt w 'X' (Kgp_placement.cell_x_offset p); 163 kv_int_opt w 'Y' (Kgp_placement.cell_y_offset p); 164 kv_int_opt w 'c' (Kgp_placement.columns p); 165 kv_int_opt w 'r' (Kgp_placement.rows p); 166 kv_int_opt w 'z' (Kgp_placement.z_index p); 167 kv_int_opt w 'p' (Kgp_placement.placement_id p); 168 Kgp_placement.cursor p 169 |> Option.iter (fun c -> 170 kv_int_if w 'C' ~default:0 (Some (Kgp_cursor.to_int c))); 171 if Kgp_placement.unicode_placeholder p then kv_int w 'U' 1 172 173let write_delete w ~free (d : Kgp_delete.t) = 174 kv_char w 'd' (Kgp_delete.to_char ~free d); 175 match d with 176 | `By_id (id, pid) -> 177 kv_int w 'i' id; 178 kv_int_opt w 'p' pid 179 | `By_number (n, pid) -> 180 kv_int w 'I' n; 181 kv_int_opt w 'p' pid 182 | `At_cell (x, y) -> 183 kv_int w 'x' x; 184 kv_int w 'y' y 185 | `At_cell_z (x, y, z) -> 186 kv_int w 'x' x; 187 kv_int w 'y' y; 188 kv_int w 'z' z 189 | `By_column c -> kv_int w 'x' c 190 | `By_row r -> kv_int w 'y' r 191 | `By_z_index z -> kv_int w 'z' z 192 | `By_id_range (min_id, max_id) -> 193 kv_int w 'x' min_id; 194 kv_int w 'y' max_id 195 | `All_visible | `At_cursor | `Frames -> () 196 197let write_frame w (f : Kgp_frame.t) = 198 kv_int_opt w 'x' (Kgp_frame.x f); 199 kv_int_opt w 'y' (Kgp_frame.y f); 200 kv_int_opt w 'c' (Kgp_frame.base_frame f); 201 kv_int_opt w 'r' (Kgp_frame.edit_frame f); 202 kv_int_opt w 'z' (Kgp_frame.gap_ms f); 203 Kgp_frame.composition f 204 |> Option.iter (fun c -> 205 kv_int_if w 'X' ~default:0 (Some (Kgp_composition.to_int c))); 206 kv_int32_opt w 'Y' (Kgp_frame.background_color f) 207 208let write_animation w : Kgp_animation.t -> unit = function 209 | `Set_state (state, loops) -> 210 let s = Kgp_animation_state.to_int state in 211 kv_int w 's' s; 212 kv_int_opt w 'v' loops 213 | `Set_gap (frame, gap_ms) -> 214 kv_int w 'r' frame; 215 kv_int w 'z' gap_ms 216 | `Set_current frame -> kv_int w 'c' frame 217 218let write_compose w (c : Kgp_compose.t) = 219 kv_int w 'r' (Kgp_compose.source_frame c); 220 kv_int w 'c' (Kgp_compose.dest_frame c); 221 kv_int_opt w 'w' (Kgp_compose.width c); 222 kv_int_opt w 'h' (Kgp_compose.height c); 223 kv_int_opt w 'x' (Kgp_compose.dest_x c); 224 kv_int_opt w 'y' (Kgp_compose.dest_y c); 225 kv_int_opt w 'X' (Kgp_compose.source_x c); 226 kv_int_opt w 'Y' (Kgp_compose.source_y c); 227 Kgp_compose.composition c 228 |> Option.iter (fun comp -> 229 kv_int_if w 'C' ~default:0 (Some (Kgp_composition.to_int comp))) 230 231let write_control_data buf cmd = 232 let w = kv_writer buf in 233 (* Action *) 234 kv_char w 'a' (action_char cmd.action); 235 (* Quiet - only if non-default *) 236 cmd.quiet 237 |> Option.iter (fun q -> 238 kv_int_if w 'q' ~default:0 (Some (Kgp_quiet.to_int q))); 239 (* Format *) 240 cmd.format 241 |> Option.iter (fun f -> kv_int w 'f' (Kgp_format.to_int f)); 242 (* Transmission - only for transmit/frame actions, always include t=d for compatibility *) 243 (match cmd.action with 244 | `Transmit | `Transmit_and_display | `Frame -> ( 245 match cmd.transmission with 246 | Some t -> kv_char w 't' (Kgp_transmission.to_char t) 247 | None -> kv_char w 't' 'd') 248 | _ -> ()); 249 (* Compression *) 250 cmd.compression 251 |> Option.iter (fun c -> 252 Kgp_compression.to_char c |> Option.iter (kv_char w 'o')); 253 (* Dimensions *) 254 kv_int_opt w 's' cmd.width; 255 kv_int_opt w 'v' cmd.height; 256 (* File size/offset *) 257 kv_int_opt w 'S' cmd.size; 258 kv_int_opt w 'O' cmd.offset; 259 (* Image ID/number *) 260 kv_int_opt w 'i' cmd.image_id; 261 kv_int_opt w 'I' cmd.image_number; 262 (* Complex options *) 263 cmd.placement |> Option.iter (write_placement w); 264 cmd.delete |> Option.iter (write_delete w ~free:cmd.delete_free); 265 cmd.frame |> Option.iter (write_frame w); 266 cmd.animation |> Option.iter (write_animation w); 267 cmd.compose |> Option.iter (write_compose w); 268 w 269 270(* Use large chunk size to avoid chunking - Kitty animation doesn't handle chunks well *) 271let chunk_size = 1024 * 1024 (* 1MB - effectively no chunking *) 272 273let write buf cmd ~data = 274 Buffer.add_string buf apc_start; 275 let w = write_control_data buf cmd in 276 if String.length data > 0 then begin 277 let encoded = Base64.encode_string data in 278 let len = String.length encoded in 279 if len <= chunk_size then ( 280 Buffer.add_char buf ';'; 281 Buffer.add_string buf encoded; 282 Buffer.add_string buf apc_end) 283 else begin 284 (* Multiple chunks *) 285 let rec write_chunks pos first = 286 if pos < len then begin 287 let remaining = len - pos in 288 let this_chunk = min chunk_size remaining in 289 let is_last = pos + this_chunk >= len in 290 if first then ( 291 kv_int w 'm' 1; 292 Buffer.add_char buf ';'; 293 Buffer.add_substring buf encoded pos this_chunk; 294 Buffer.add_string buf apc_end) 295 else ( 296 Buffer.add_string buf apc_start; 297 Buffer.add_string buf (if is_last then "m=0" else "m=1"); 298 Buffer.add_char buf ';'; 299 Buffer.add_substring buf encoded pos this_chunk; 300 Buffer.add_string buf apc_end); 301 write_chunks (pos + this_chunk) false 302 end 303 in 304 write_chunks 0 true 305 end 306 end 307 else Buffer.add_string buf apc_end 308 309let to_string cmd ~data = 310 let buf = Buffer.create 1024 in 311 write buf cmd ~data; 312 Buffer.contents buf 313 314let write_tmux buf cmd ~data = 315 if Kgp_tmux.is_active () then begin 316 let inner_buf = Buffer.create 1024 in 317 write inner_buf cmd ~data; 318 Kgp_tmux.write_wrapped buf (Buffer.contents inner_buf) 319 end else 320 write buf cmd ~data 321 322let to_string_tmux cmd ~data = 323 let buf = Buffer.create 1024 in 324 write_tmux buf cmd ~data; 325 Buffer.contents buf