diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e35d885 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +_build diff --git a/.ocamlformat b/.ocamlformat new file mode 100644 index 0000000..e69de29 diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..fb063d4 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,13 @@ +# Changes + +## 0.0.1 + +Initial release, including: + +* High-level `Terminal` module for controlling a terminal +* Async-input with UTF-8 support in the `Stdin` module +* Terminal `Profile`s for determining what color palettes are available +* A `Color` module for parsing and working with RGB/ANSI/ANSI256 colors +* A collection of 60 escape sequence functions in `Escape_seq` +* a lot of room for improvement! + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b208ec8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2023, Leandro Ostera + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..23ac4e2 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# TTY + +TTY is a pure OCaml library for directly interacting with the +terminal, including escape sequences, color profiles, colors, and +consuming stdin. + +It is the main backend for [MintTea][minttea]. + +[minttea]: https://github.com/leostera/minttea + +## Getting Started + +``` +$ opam install tty +``` diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..51addb8 --- /dev/null +++ b/dune-project @@ -0,0 +1,24 @@ +(lang dune 3.11) + +(name tty) + +(generate_opam_files true) + +(source (github leostera/tty)) + +(authors "Leandro Ostera ") + +(maintainers "Leandro Ostera ") + +(license MIT) + +(package + (name tty) + (synopsis "A library for interacting with teletype and terminal emulators") + (description "TTY is a library for directly interacting with teletypes and terminal emulators, including escape sequences, colors, and consuming stdin") + (depends + (ocaml (>= "5.1")) + (dune (>= "3.11")) + (uutf (>= "1.0.3"))) + (tags (terminal ansi tty teletype utf8))) + diff --git a/tty.opam b/tty.opam new file mode 100644 index 0000000..0e51449 --- /dev/null +++ b/tty.opam @@ -0,0 +1,32 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "A library for interacting with teletype and terminal emulators" +description: + "TTY is a library for directly interacting with teletypes and terminal emulators, including escape sequences, colors, and consuming stdin" +maintainer: ["Leandro Ostera "] +authors: ["Leandro Ostera "] +license: "MIT" +tags: ["terminal" "ansi" "tty" "teletype" "utf8"] +homepage: "https://github.com/leostera/tty" +bug-reports: "https://github.com/leostera/tty/issues" +depends: [ + "ocaml" {>= "5.1"} + "dune" {>= "3.11" & >= "3.11"} + "uutf" {>= "1.0.3"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] +dev-repo: "git+https://github.com/leostera/tty.git" diff --git a/tty/color.ml b/tty/color.ml new file mode 100644 index 0000000..b546bf2 --- /dev/null +++ b/tty/color.ml @@ -0,0 +1,45 @@ +type t = RGB of int * int * int | ANSI of int | ANSI256 of int | No_color + +exception Invalid_color of string +exception Invalid_color_param of string +exception Invalid_color_num of string * int + +let to_255 str = + match int_of_string_opt ("0x" ^ str) with + | None -> raise (Invalid_color_param str) + | Some c -> c + +let rgb r g b = RGB (to_255 r, to_255 g, to_255 b) + +let rgb str = + match String.to_seq str |> List.of_seq |> List.map (String.make 1) with + | [ "#"; r1; r2; g1; g2; b1; b2 ] -> rgb (r1 ^ r2) (g1 ^ g2) (b1 ^ b2) + | [ "#"; r1; g1; b1 ] -> rgb r1 g1 b1 + | _ -> raise (Invalid_color str) + +let ansi i = ANSI i +let ansi256 i = ANSI256 i +let no_color = No_color + +let make str = + if String.starts_with ~prefix:"#" str then rgb str + else + match int_of_string_opt str with + | None -> raise (Invalid_color str) + | Some i when i < 16 -> ansi i + | Some i -> ansi256 i + +let to_escape_seq ~mode t = + match t with + | RGB (r, g, b) -> Format.sprintf "2;%d;%d;%d" r g b + | ANSI c -> + let bg_mod x = if mode = `bg then x + 10 else x in + let c = if c < 8 then bg_mod c + 30 else bg_mod (c - 8) + 90 in + Int.to_string c + | ANSI256 c -> Format.sprintf "5;%d" c + | No_color -> "" + +let is_no_color t = t = No_color +let is_rgb t = match t with RGB _ -> true | _ -> false +let is_ansi t = match t with ANSI _ -> true | _ -> false +let is_ansi256 t = match t with ANSI256 _ -> true | _ -> false diff --git a/tty/color.mli b/tty/color.mli new file mode 100644 index 0000000..400f256 --- /dev/null +++ b/tty/color.mli @@ -0,0 +1,18 @@ +type t = private + | RGB of int * int * int + | ANSI of int + | ANSI256 of int + | No_color + +val make : string -> t + +exception Invalid_color of string +exception Invalid_color_param of string +exception Invalid_color_num of string * int + +val no_color : t +val is_no_color : t -> bool +val is_rgb : t -> bool +val is_ansi : t -> bool +val is_ansi256 : t -> bool +val to_escape_seq : mode:[> `bg | `fg ] -> t -> string diff --git a/tty/dune b/tty/dune new file mode 100644 index 0000000..df012fc --- /dev/null +++ b/tty/dune @@ -0,0 +1,4 @@ +(library + (public_name tty) + (name tty) + (libraries uutf unix)) diff --git a/tty/escape_seq.ml b/tty/escape_seq.ml new file mode 100644 index 0000000..5336bd0 --- /dev/null +++ b/tty/escape_seq.ml @@ -0,0 +1,73 @@ +let csi = "\x1b[" +let reset_seq = "0" +let bold_seq = "1" +let faint_seq = "2" +let italics_seq = "3" +let underline_seq = "4" +let blink_seq = "5" +let reverse_seq = "7" +let cross_out_seq = "9" +let overline_seq = "53" +let foreground_seq = "38" +let background_seq = "48" +let escape code () = Printf.printf "%s%s%!" csi code + +(* Cursor positioning. *) +let cursor_up_seq x = escape (Printf.sprintf "%dA" x) +let cursor_down_seq x = escape (Printf.sprintf "%dB" x) +let cursor_forward_seq x = escape (Printf.sprintf "%dC" x) +let cursor_back_seq x = escape (Printf.sprintf "%dD" x) +let cursor_next_line_seq x = escape (Printf.sprintf "%dE" x) +let cursor_previous_line_seq x = escape (Printf.sprintf "%dF" x) +let cursor_horizontal_seq x = escape (Printf.sprintf "%dG" x) +let cursor_position_seq x y = escape (Printf.sprintf "%d;%dH" x y) +let erase_display_seq x = escape (Printf.sprintf "%dJ" x) +let erase_line_seq x = escape (Printf.sprintf "%dK" x) +let scroll_up_seq x = escape (Printf.sprintf "%dS" x) +let scroll_down_seq x = escape (Printf.sprintf "%dT" x) +let save_cursor_position_seq = escape "s" +let restore_cursor_position_seq = escape "u" +let change_scrolling_region_seq x y = escape (Printf.sprintf "%d;%dr" x y) +let insert_line_seq x = escape (Printf.sprintf "%dL" x) +let delete_line_seq x = escape (Printf.sprintf "%dM" x) + +(* Explicit values for EraseLineSeq. *) +let erase_line_right_seq = escape "0K" +let erase_line_left_seq = escape "1K" +let erase_entire_line_seq = escape "2K" + +(* Mouse. *) +let enable_mouse_press_seq = escape "?9h" +let disable_mouse_press_seq = escape "?9l" +let enable_mouse_seq = escape "?1000h" +let disable_mouse_seq = escape "?1000l" +let enable_mouse_hilite_seq = escape "?1001h" +let disable_mouse_hilite_seq = escape "?1001l" +let enable_mouse_cell_motion_seq = escape "?1002h" +let disable_mouse_cell_motion_seq = escape "?1002l" +let enable_mouse_all_motion_seq = escape "?1003h" +let disable_mouse_all_motion_seq = escape "?1003l" +let enable_mouse_extended_mode_seq = escape "?1006h" +let disable_mouse_extended_mode_seq = escape "?1006l" +let enable_mouse_pixels_mode_seq = escape "?1016h" +let disable_mouse_pixels_mode_seq = escape "?1016l" + +(* Screen. *) +let restore_screen_seq = escape "?47l" +let save_screen_seq = escape "?47h" +let alt_screen_seq = escape "?1049h" +let exit_alt_screen_seq = escape "?1049l" + +(* Bracketed paste. *) +let enable_bracketed_paste_seq = escape "?2004h" +let disable_bracketed_paste_seq = escape "?2004l" +let start_bracketed_paste_seq = escape "200~" +let end_bracketed_paste_seq = escape "201~" + +(* Session. *) +let set_window_title_seq s = escape (Printf.sprintf "2;%s" s) +let set_foreground_color_seq s = escape (Printf.sprintf "10;%s" s) +let set_background_color_seq s = escape (Printf.sprintf "11;%s" s) +let set_cursor_color_seq s = escape (Printf.sprintf "12;%s" s) +let show_cursor_seq = escape "?25h" +let hide_cursor_seq = escape "?25l" diff --git a/tty/escape_seq.mli b/tty/escape_seq.mli new file mode 100644 index 0000000..0f9332d --- /dev/null +++ b/tty/escape_seq.mli @@ -0,0 +1,60 @@ +val csi : string +val reset_seq : string +val bold_seq : string +val faint_seq : string +val italics_seq : string +val underline_seq : string +val blink_seq : string +val reverse_seq : string +val cross_out_seq : string +val overline_seq : string +val foreground_seq : string +val background_seq : string +val alt_screen_seq : unit -> unit +val change_scrolling_region_seq : int -> int -> unit -> unit +val cursor_back_seq : int -> unit -> unit +val cursor_down_seq : int -> unit -> unit +val cursor_forward_seq : int -> unit -> unit +val cursor_horizontal_seq : int -> unit -> unit +val cursor_next_line_seq : int -> unit -> unit +val cursor_position_seq : int -> int -> unit -> unit +val cursor_previous_line_seq : int -> unit -> unit +val cursor_up_seq : int -> unit -> unit +val delete_line_seq : int -> unit -> unit +val disable_bracketed_paste_seq : unit -> unit +val disable_mouse_all_motion_seq : unit -> unit +val disable_mouse_cell_motion_seq : unit -> unit +val disable_mouse_extended_mode_seq : unit -> unit +val disable_mouse_hilite_seq : unit -> unit +val disable_mouse_pixels_mode_seq : unit -> unit +val disable_mouse_press_seq : unit -> unit +val disable_mouse_seq : unit -> unit +val enable_bracketed_paste_seq : unit -> unit +val enable_mouse_all_motion_seq : unit -> unit +val enable_mouse_cell_motion_seq : unit -> unit +val enable_mouse_extended_mode_seq : unit -> unit +val enable_mouse_hilite_seq : unit -> unit +val enable_mouse_pixels_mode_seq : unit -> unit +val enable_mouse_press_seq : unit -> unit +val enable_mouse_seq : unit -> unit +val end_bracketed_paste_seq : unit -> unit +val erase_display_seq : int -> unit -> unit +val erase_entire_line_seq : unit -> unit +val erase_line_left_seq : unit -> unit +val erase_line_right_seq : unit -> unit +val erase_line_seq : int -> unit -> unit +val exit_alt_screen_seq : unit -> unit +val hide_cursor_seq : unit -> unit +val insert_line_seq : int -> unit -> unit +val restore_cursor_position_seq : unit -> unit +val restore_screen_seq : unit -> unit +val save_cursor_position_seq : unit -> unit +val save_screen_seq : unit -> unit +val scroll_down_seq : int -> unit -> unit +val scroll_up_seq : int -> unit -> unit +val set_background_color_seq : string -> unit -> unit +val set_cursor_color_seq : string -> unit -> unit +val set_foreground_color_seq : string -> unit -> unit +val set_window_title_seq : string -> unit -> unit +val show_cursor_seq : unit -> unit +val start_bracketed_paste_seq : unit -> unit diff --git a/tty/profile.ml b/tty/profile.ml new file mode 100644 index 0000000..d2bf972 --- /dev/null +++ b/tty/profile.ml @@ -0,0 +1,42 @@ +type t = No_color | ANSI | ANSI256 | True_color + +let from_env () = + let term = Sys.getenv_opt "TERM" in + let color_term = Sys.getenv_opt "COLORTERM" in + let term_program = Sys.getenv_opt "TERM_PROGRAM" in + + let is_screen = + match term with + | Some term -> String.starts_with ~prefix:"screen" term + | None -> false + in + + let is_tmux = match term_program with Some "tmux" -> true | _ -> false in + + (* TODO(@leostera): String.contains "256color" "color" "ansi" *) + let is_256color = false in + let is_color = false in + let is_ansi = false in + + match (term, color_term) with + | _, Some "true" -> ANSI256 + | _, Some "truecolor" when is_screen && not is_tmux -> ANSI256 + | _, Some "truecolor" -> True_color + | Some ("xterm-kitty" | "wezterm"), _ -> True_color + | Some "linux", _ -> ANSI + | Some _, _ when is_256color -> ANSI256 + | Some _, _ when is_color || is_ansi -> ANSI + | _ -> No_color + +let default = from_env () + +let convert profile color = + match (color, profile) with + | _, No_color -> Color.no_color + | Color.No_color, _ -> Color.no_color + | Color.ANSI _, _ -> color + | Color.ANSI256 _, ANSI -> color + | Color.ANSI256 _, _ -> color + | Color.RGB _, ANSI -> color + | Color.RGB _, ANSI256 -> color + | Color.RGB _, True_color -> color diff --git a/tty/profile.mli b/tty/profile.mli new file mode 100644 index 0000000..800bc56 --- /dev/null +++ b/tty/profile.mli @@ -0,0 +1,5 @@ +type t + +val from_env : unit -> t +val default : t +val convert : t -> Color.t -> Color.t diff --git a/tty/stdin.ml b/tty/stdin.ml new file mode 100644 index 0000000..6fa6c7f --- /dev/null +++ b/tty/stdin.ml @@ -0,0 +1,39 @@ +let stdin_fd = Unix.descr_of_in_channel stdin +let decoder = Uutf.decoder ~encoding:`UTF_8 `Manual + +let set_raw_mode () = + let termios = Unix.tcgetattr stdin_fd in + let new_termios = + Unix. + { termios with c_icanon = false; c_echo = false; c_vmin = 1; c_vtime = 0 } + in + Unix.tcsetattr stdin_fd TCSANOW new_termios; + termios + +let restore_mode termios = Unix.tcsetattr stdin_fd TCSANOW termios + +let try_read () = + let bytes = Bytes.create 8 in + match Unix.read stdin_fd bytes 0 8 with + | exception Unix.(Unix_error ((EINTR | EAGAIN | EWOULDBLOCK), _, _)) -> () + | len -> Uutf.Manual.src decoder bytes 0 len + +let uchar_to_str u = + let buf = Buffer.create 8 in + Uutf.Buffer.add_utf_8 buf u; + Buffer.contents buf + +let read_utf8 () = + match Uutf.decode decoder with + | `Uchar u -> `Read (uchar_to_str u) + | `End -> `End + | `Await -> + try_read (); + `Retry + | `Malformed err -> `Malformed err + +let setup () = + Unix.set_nonblock stdin_fd; + set_raw_mode () + +let shutdown termios = restore_mode termios diff --git a/tty/stdin.mli b/tty/stdin.mli new file mode 100644 index 0000000..d56609e --- /dev/null +++ b/tty/stdin.mli @@ -0,0 +1,9 @@ +val read_utf8 : + unit -> [> `Retry | `End | `Malformed of string | `Read of string ] +(** [read_utf8 ()] will do a non-blocking read and either return the next valid UTF-8 string available in [stdin] or immediately return. *) + +val setup : unit -> Unix.terminal_io +(** [setup ()] sets up the [stdin] for async reading. *) + +val shutdown : Unix.terminal_io -> unit +(** [shutdown ()] restores the [stdin].*) diff --git a/tty/terminal.ml b/tty/terminal.ml new file mode 100644 index 0000000..33d5592 --- /dev/null +++ b/tty/terminal.ml @@ -0,0 +1,11 @@ +let clear () = + Escape_seq.erase_display_seq 2 (); + Escape_seq.cursor_position_seq 1 1 () + +let clear_line () = Escape_seq.erase_entire_line_seq () +let cursor_down x = Escape_seq.cursor_down_seq x () +let cursor_up x = Escape_seq.cursor_up_seq x () +let cursor_back x = Escape_seq.cursor_back_seq x () +let enter_alt_screen () = Escape_seq.alt_screen_seq () +let exit_alt_screen () = Escape_seq.exit_alt_screen_seq () +let move_cursor x y = Escape_seq.cursor_position_seq x y ()