Terminal styling and layout widgets for OCaml (tables, trees, panels, colors)
at main 271 lines 8.4 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 SPDX-License-Identifier: MIT 4 ---------------------------------------------------------------------------*) 5 6(** {1 Progress Bar State} *) 7 8type state = { total : int; current : int; message : string; phase : string } 9 10let state ?(phase = "") ?(current = 0) ~total message = 11 { total; current; message; phase } 12 13let with_current current s = { s with current } 14let with_message message s = { s with message } 15let with_phase phase s = { s with phase } 16let incr s = { s with current = s.current + 1 } 17 18let update ?(phase = "") ~message s = 19 { s with current = s.current + 1; message; phase } 20 21(** {1 Configuration} *) 22 23type config = { 24 ppf : Format.formatter; 25 width : int; 26 style : [ `ASCII | `UTF8 | `Plain ]; 27 bar_width : int; 28} 29 30let config ?(ppf = Format.std_formatter) ?width ?(style = `UTF8) 31 ?(bar_width = 25) () = 32 let width = 33 match width with Some w -> w | None -> Width.terminal_width () 34 in 35 { ppf; width; style; bar_width } 36 37(** {1 Rendering} *) 38 39(* Braille spinner frames *) 40let spinner_utf8 = [| ""; ""; ""; ""; ""; ""; ""; ""; ""; "" |] 41 42let spinner_ascii = [| "|"; "/"; "-"; "\\" |] 43 44(* Bar characters for smooth animation *) 45let bar_chars = [| " "; ""; ""; ""; ""; ""; ""; ""; "" |] 46 47(* ANSI codes *) 48let cyan = "\027[36m" 49let green = "\027[32m" 50let dim = "\027[2m" 51let reset_code = "\027[0m" 52let hide_cursor = "\027[?25l" 53let show_cursor = "\027[?25h" 54 55(* Ensure cursor is restored on exit (handles Ctrl-C, uncaught exceptions) *) 56let cursor_hidden = ref false 57 58let restore_cursor () = 59 if !cursor_hidden then begin 60 output_string stdout show_cursor; 61 flush stdout; 62 cursor_hidden := false 63 end 64 65let () = at_exit restore_cursor 66 67let render_bar_utf8 ~bar_width ~pct = 68 let total_eighths = bar_width * 8 in 69 let filled_eighths = total_eighths * pct / 100 in 70 let full_blocks = filled_eighths / 8 in 71 let partial = filled_eighths mod 8 in 72 let empty_blocks = bar_width - full_blocks - if partial > 0 then 1 else 0 in 73 let full = 74 String.concat "" (List.init full_blocks (fun _ -> bar_chars.(8))) 75 in 76 let part = if partial > 0 then bar_chars.(partial) else "" in 77 let empty = 78 String.concat "" (List.init empty_blocks (fun _ -> bar_chars.(0))) 79 in 80 full ^ part ^ empty 81 82let render_bar_ascii ~bar_width ~pct = 83 let filled = bar_width * pct / 100 in 84 let empty = max 0 (bar_width - filled - 1) in 85 String.make filled '=' 86 ^ (if filled < bar_width then ">" else "") 87 ^ String.make empty ' ' 88 89let pad_to_width width s = 90 let w = Width.string_width s in 91 if w < width then s ^ String.make (width - w) ' ' else s 92 93let render ~frame cfg s = 94 let pct = if s.total > 0 then min 100 (s.current * 100 / s.total) else 0 in 95 let counter = Fmt.str "%d/%d" s.current s.total in 96 let full_msg = 97 if s.phase <> "" then s.phase ^ ": " ^ s.message else s.message 98 in 99 match cfg.style with 100 | `UTF8 -> 101 let bar = render_bar_utf8 ~bar_width:cfg.bar_width ~pct in 102 let spinner = spinner_utf8.(frame mod Array.length spinner_utf8) in 103 (* Calculate prefix width to determine how much space is left for message *) 104 let prefix = Fmt.str "%s ▕%s▏ %3d%% %s " spinner bar pct counter in 105 let prefix_width = Width.string_width prefix in 106 let max_msg_len = max 0 (cfg.width - prefix_width) in 107 let display_msg = 108 if String.length full_msg > max_msg_len then 109 String.sub full_msg 0 (max 0 (max_msg_len - 1)) ^ "" 110 else full_msg 111 in 112 let visible_width = prefix_width + Width.string_width display_msg in 113 let padding = 114 if visible_width < cfg.width then 115 String.make (cfg.width - visible_width) ' ' 116 else "" 117 in 118 Fmt.str "%s%s%s ▕%s%s%s▏ %3d%% %s%s%s %s%s" cyan spinner reset_code green 119 bar reset_code pct dim counter reset_code display_msg padding 120 | `ASCII -> 121 let bar = render_bar_ascii ~bar_width:cfg.bar_width ~pct in 122 let spinner = spinner_ascii.(frame mod Array.length spinner_ascii) in 123 let prefix = Fmt.str "%s [%s] %3d%% %s " spinner bar pct counter in 124 let prefix_width = Width.string_width prefix in 125 let max_msg_len = max 0 (cfg.width - prefix_width) in 126 let display_msg = 127 if String.length full_msg > max_msg_len then 128 String.sub full_msg 0 (max 0 (max_msg_len - 3)) ^ "..." 129 else full_msg 130 in 131 pad_to_width cfg.width (prefix ^ display_msg) 132 | `Plain -> 133 (* Minimal: no spinner, no colors, just counter and message *) 134 pad_to_width cfg.width (Fmt.str "[%3d%%] %s %s" pct counter full_msg) 135 136(** {1 Imperative Wrapper} *) 137 138type t = { 139 cfg : config; 140 mutable s : state; 141 mutable frame : int; 142 mutable finished : bool; 143 enabled : bool; 144 start_time : float; 145} 146 147let debug = 148 match Sys.getenv_opt "TTY_PROGRESS_DEBUG" with 149 | Some _ -> true 150 | None -> false 151 152let output_line ppf line = 153 (* Use direct stdout when ppf is std_formatter to bypass Format line-breaking *) 154 let use_direct = ppf == Format.std_formatter in 155 if debug then 156 Fmt.epr "[PROGRESS] use_direct=%b is_tty=%b line_bytes=%d line_width=%d\n" 157 use_direct (Width.is_tty ()) (String.length line) 158 (Width.string_width line); 159 (* Use \r\027[K to: move to start of line, clear to end of line *) 160 let cr_clear = "\r\027[K" in 161 if use_direct then begin 162 output_string stdout cr_clear; 163 output_string stdout line; 164 flush stdout 165 end 166 else begin 167 (* For buffers/custom formatters, use Format but disable line-breaking *) 168 let old_margin = Format.pp_get_margin ppf () in 169 Format.pp_set_margin ppf max_int; 170 Fmt.string ppf cr_clear; 171 Fmt.string ppf line; 172 Format.pp_print_flush ppf (); 173 Format.pp_set_margin ppf old_margin 174 end 175 176let v ?(ppf = Format.std_formatter) ?width ?enabled ?(style = `UTF8) ~total msg 177 = 178 let cfg = config ~ppf ?width ~style () in 179 let enabled = match enabled with Some e -> e | None -> Width.is_tty () in 180 let s = state ~total msg in 181 let start_time = Unix.gettimeofday () in 182 let t = { cfg; s; frame = 0; finished = false; enabled; start_time } in 183 (* Hide cursor and render immediately *) 184 if enabled && total > 0 then begin 185 if ppf == Format.std_formatter then begin 186 cursor_hidden := true; 187 output_string stdout hide_cursor; 188 flush stdout 189 end; 190 let line = render ~frame:0 cfg s in 191 output_line ppf line 192 end; 193 t 194 195let position t = t.s.current 196 197let pp ppf t = 198 let status = if t.finished then "finished" else "active" in 199 Fmt.pf ppf "progress(%s %d/%d %S)" status t.s.current t.s.total t.s.message 200 201let display t = 202 if t.enabled && not t.finished then begin 203 (* Time-based frame for faster spinner: ~10 frames per second *) 204 let elapsed = Unix.gettimeofday () -. t.start_time in 205 let frame = int_of_float (elapsed *. 10.) in 206 t.frame <- frame; 207 let line = render ~frame t.cfg t.s in 208 output_line t.cfg.ppf line 209 end 210 211let set t pos = 212 t.s <- with_current pos t.s; 213 display t 214 215let tick t = 216 t.s <- incr t.s; 217 display t 218 219let message t msg = 220 t.s <- with_message msg t.s; 221 display t 222 223let set_phase t phase = 224 t.s <- with_phase phase t.s; 225 display t 226 227let update t ~phase ~msg = 228 t.s <- update ~phase ~message:msg t.s; 229 display t 230 231let reset t ~total msg = 232 t.s <- state ~total msg; 233 t.frame <- 0; 234 t.finished <- false; 235 display t 236 237let finish t = 238 if not t.finished then begin 239 t.s <- with_current t.s.total t.s; 240 display t; 241 t.finished <- true; 242 if t.enabled then begin 243 if t.cfg.ppf == Format.std_formatter then begin 244 cursor_hidden := false; 245 output_string stdout show_cursor; 246 output_char stdout '\n'; 247 flush stdout 248 end 249 else begin 250 Format.pp_print_newline t.cfg.ppf (); 251 Format.pp_print_flush t.cfg.ppf () 252 end 253 end 254 end 255 256let clear t = 257 if not t.finished then begin 258 t.finished <- true; 259 if t.enabled then begin 260 if t.cfg.ppf == Format.std_formatter then begin 261 cursor_hidden := false; 262 output_string stdout "\r\027[K"; 263 output_string stdout show_cursor; 264 flush stdout 265 end 266 else begin 267 Fmt.string t.cfg.ppf "\r\027[K"; 268 Format.pp_print_flush t.cfg.ppf () 269 end 270 end 271 end