├── test ├── test_helper.exs └── termite │ ├── screen_test.exs │ └── style_test.exs ├── .formatter.exs ├── examples ├── termite.exs ├── colors.exs ├── demo.exs └── snake.exs ├── .gitignore ├── lib └── termite │ ├── terminal │ ├── adapter.ex │ └── prim_tty.ex │ ├── terminal.ex │ ├── style.ex │ └── screen.ex ├── mix.exs ├── LICENCE.md ├── include ├── prim_tty_26_0.hrl ├── prim_tty_26_2_5_3.hrl ├── prim_tty_27_0.hrl └── prim_tty_28_0.hrl ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/termite/screen_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Termite.ScreenTest do 2 | use ExUnit.Case, async: true 3 | doctest Termite.Screen 4 | end 5 | -------------------------------------------------------------------------------- /examples/termite.exs: -------------------------------------------------------------------------------- 1 | dot_count = 10 2 | string = String.duplicate(".", 10) <> "🪳" 3 | 4 | clear = fn terminal -> 5 | terminal 6 | |> tap(fn _ -> :timer.sleep(1000) end) 7 | |> Termite.Screen.cursor_back(3) 8 | |> Termite.Screen.delete_chars() 9 | end 10 | 11 | terminal = 12 | Termite.Terminal.start() 13 | |> Termite.Terminal.write(string) 14 | |> Termite.Screen.hide_cursor() 15 | |> Termite.Screen.cursor_back(2) 16 | 17 | terminal = Enum.reduce(1..dot_count, terminal, fn _, term -> clear.(term) end) 18 | 19 | Termite.Screen.show_cursor(terminal) 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | termite-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/termite/terminal/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Termite.Terminal.Adapter do 2 | @moduledoc """ 3 | The behaviour module for implementing an adapter. 4 | """ 5 | 6 | @doc """ 7 | Start the terminal adapter. 8 | """ 9 | @callback start(opts :: %{}) :: {:ok, term} | {:error, atom} 10 | 11 | @doc """ 12 | Return a reference for the reader for handling input messages. 13 | """ 14 | @callback reader(terminal :: term) :: {:ok, reference} | {:error, atom} 15 | 16 | @doc """ 17 | Write a string to the terminal. This function is expected to be implemented 18 | synchronously in the adapter. 19 | """ 20 | @callback write(terminal :: term, string :: String.t()) :: {:ok, term} | {:error, atom} 21 | 22 | @doc """ 23 | Returns a map of the width and height 24 | """ 25 | @callback resize(terminal :: term) :: %{width: integer(), height: integer()} 26 | end 27 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Termite.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.1" 5 | 6 | def project do 7 | [ 8 | app: :termite, 9 | description: "A dependency-free NIF-free terminal library for Elixir.", 10 | package: package(), 11 | version: @version, 12 | elixir: "~> 1.16", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | name: "Termite", 16 | source_url: "https://github.com/Gazler/termite", 17 | docs: [ 18 | source_ref: "v#{@version}" 19 | ] 20 | ] 21 | end 22 | 23 | def application do 24 | [] 25 | end 26 | 27 | defp package() do 28 | [ 29 | files: ~w(include lib .formatter.exs mix.exs README.md LICENCE.md), 30 | licenses: ["MIT"], 31 | links: %{"GitHub" => "https://github.com/Gazler/termite"} 32 | ] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:ex_doc, "~> 0.34", only: :dev, runtime: false} 38 | ] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2024 Gary Rennie 3 | 4 | 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: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | 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. 9 | -------------------------------------------------------------------------------- /include/prim_tty_26_0.hrl: -------------------------------------------------------------------------------- 1 | -record(state, {tty, 2 | reader, 3 | writer, 4 | options, 5 | unicode, 6 | lines_before = [], %% All lines before the current line in reverse order 7 | lines_after = [], %% All lines after the current line. 8 | buffer_before = [], %% Current line before cursor in reverse 9 | buffer_after = [], %% Current line after cursor not in reverse 10 | buffer_expand, %% Characters in expand buffer 11 | cols = 80, 12 | rows = 24, 13 | xn = false, 14 | clear = <<"\e[H\e[2J">>, 15 | up = <<"\e[A">>, 16 | down = <<"\n">>, 17 | left = <<"\b">>, 18 | right = <<"\e[C">>, 19 | %% Tab to next 8 column windows is "\e[1I", for unix "ta" termcap 20 | tab = <<"\e[1I">>, 21 | delete_after_cursor = <<"\e[J">>, 22 | insert = false, 23 | delete = false, 24 | position = <<"\e[6n">>, %% "u7" on my Linux 25 | position_reply = <<"\e\\[([0-9]+);([0-9]+)R">>, 26 | ansi_regexp 27 | }). 28 | -------------------------------------------------------------------------------- /include/prim_tty_26_2_5_3.hrl: -------------------------------------------------------------------------------- 1 | -record(state, {tty :: tty() | undefined, 2 | reader :: {pid(), reference()} | undefined, 3 | writer :: {pid(), reference()} | undefined, 4 | options, 5 | redraw_prompt_on_output = true, 6 | unicode = true :: boolean(), 7 | lines_before = [], %% All lines before the current line in reverse order 8 | lines_after = [], %% All lines after the current line. 9 | buffer_before = [], %% Current line before cursor in reverse 10 | buffer_after = [], %% Current line after cursor not in reverse 11 | buffer_expand, %% Characters in expand buffer 12 | cols = 80, 13 | rows = 24, 14 | xn = false, 15 | clear = <<"\e[H\e[2J">>, 16 | up = <<"\e[A">>, 17 | down = <<"\n">>, 18 | left = <<"\b">>, 19 | right = <<"\e[C">>, 20 | %% Tab to next 8 column windows is "\e[1I", for unix "ta" termcap 21 | tab = <<"\e[1I">>, 22 | delete_after_cursor = <<"\e[J">>, 23 | insert = false, %% Not used 24 | delete = false, %% Not used 25 | position = <<"\e[6n">>, %% "u7" on my Linux, Not used 26 | position_reply = <<"\e\\[([0-9]+);([0-9]+)R">>, %% Not used 27 | ansi_regexp 28 | }). 29 | -------------------------------------------------------------------------------- /lib/termite/terminal.ex: -------------------------------------------------------------------------------- 1 | defmodule Termite.Terminal do 2 | @moduledoc """ 3 | This module provides an interface for interacting with the terminal specified. 4 | """ 5 | defstruct [:adapter, :reader, :size] 6 | 7 | @doc """ 8 | Start the terminal. 9 | 10 | ## Options 11 | 12 | * `:adapter` - determines the adapter to use. Defaults to `Termite.Terminal.PrimTTY` 13 | 14 | All other options are passed directly to the adapter. 15 | """ 16 | def start(opts \\ []) do 17 | {adapter, opts} = Keyword.pop(opts, :adapter, Termite.Terminal.PrimTTY) 18 | {:ok, term} = adapter.start(opts) 19 | {:ok, ref} = adapter.reader(term) 20 | resize(%__MODULE__{reader: ref, adapter: {adapter, term}}) 21 | end 22 | 23 | @doc """ 24 | Write a string to the terminal. 25 | 26 | See `Termite.Screen` and `Termite.Style` for documentation on escape codes. 27 | """ 28 | def write(state, str) do 29 | %{adapter: {adapter, term}} = state 30 | {:ok, term} = adapter.write(term, str) 31 | %{state | adapter: {adapter, term}} 32 | end 33 | 34 | @doc """ 35 | Update the size of the terminal. 36 | """ 37 | def resize(state) do 38 | %{adapter: {adapter, term}} = state 39 | %{state | size: adapter.resize(term)} 40 | end 41 | 42 | @doc """ 43 | Wait for input from the terminal. 44 | """ 45 | def poll(state, timeout \\ :infinity) do 46 | %{reader: ref} = state 47 | 48 | receive do 49 | {^ref, message} -> message 50 | after 51 | timeout -> :timeout 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /include/prim_tty_27_0.hrl: -------------------------------------------------------------------------------- 1 | -record(state, {tty :: tty() | undefined, 2 | reader :: {pid(), reference()} | undefined, 3 | writer :: {pid(), reference()} | undefined, 4 | options, 5 | unicode = true :: boolean(), 6 | lines_before = [], %% All lines before the current line in reverse order 7 | lines_after = [], %% All lines after the current line. 8 | buffer_before = [], %% Current line before cursor in reverse 9 | buffer_after = [], %% Current line after cursor not in reverse 10 | buffer_expand, %% Characters in expand buffer 11 | buffer_expand_row = 1, 12 | buffer_expand_limit = 0 :: non_neg_integer(), 13 | cols = 80, 14 | rows = 24, 15 | xn = false, 16 | clear = <<"\e[H\e[2J">>, 17 | up = <<"\e[A">>, 18 | down = <<"\n">>, 19 | left = <<"\b">>, 20 | right = <<"\e[C">>, 21 | %% Tab to next 8 column windows is "\e[1I", for unix "ta" termcap 22 | tab = <<"\e[1I">>, 23 | delete_after_cursor = <<"\e[J">>, 24 | insert = false, %% Not used 25 | delete = false, %% Not used 26 | position = <<"\e[6n">>, %% "u7" on my Linux, Not used 27 | position_reply = <<"\e\\[([0-9]+);([0-9]+)R">>, %% Not used 28 | ansi_regexp 29 | }). 30 | -------------------------------------------------------------------------------- /include/prim_tty_28_0.hrl: -------------------------------------------------------------------------------- 1 | -record(state, {tty :: tty() | undefined, 2 | reader :: {pid(), reference()} | undefined, 3 | writer :: {pid(), reference()} | undefined, 4 | options = #{ input => cooked, output => cooked } :: options(), 5 | unicode = true :: boolean(), 6 | lines_before = [], %% All lines before the current line in reverse order 7 | lines_after = [], %% All lines after the current line. 8 | buffer_before = [], %% Current line before cursor in reverse 9 | buffer_after = [], %% Current line after cursor not in reverse 10 | buffer_expand, %% Characters in expand buffer 11 | buffer_expand_row = 1, 12 | buffer_expand_limit = 0 :: non_neg_integer(), 13 | putc_buffer = <<>>, %% Buffer for putc containing the last row of characters 14 | cols = 80, 15 | rows = 24, 16 | xn = false, 17 | clear = <<"\e[H\e[2J">>, 18 | up = <<"\e[A">>, 19 | down = <<"\n">>, 20 | left = <<"\b">>, 21 | right = <<"\e[C">>, 22 | %% Tab to next 8 column windows is "\e[1I", for unix "ta" termcap 23 | tab = <<"\e[1I">>, 24 | delete_after_cursor = <<"\e[J">>, 25 | insert = false, %% Not used 26 | delete = false, %% Not used 27 | position = <<"\e[6n">>, %% "u7" on my Linux, Not used 28 | position_reply = <<"\e\\[([0-9]+);([0-9]+)R">>, %% Not used 29 | ansi_regexp 30 | }). 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🪳 Termite 2 | 3 | A dependency-free NIF-free terminal library for Elixir. 4 | 5 | ## Features 6 | 7 | * no dependencies 8 | * no NIF required by default 9 | * Is tty 10 | * support for cursor navigation 11 | * support for text styles 12 | * support for ANSI and ANSI-256 styles 13 | * support for alt screen 14 | * support for keyboard events 15 | 16 | ## Installation 17 | 18 | Termite requires OTP-26 or above. 19 | 20 | He package can be installed by adding `termite` to your list of dependencies in `mix.exs`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:termite, "~> 0.3.0"} 26 | ] 27 | end 28 | ``` 29 | 30 | ## Examples 31 | 32 | It is not recommended to call Termite.Terminal.start() in iex, instead create a script and run it 33 | using `mix run` as it can change the way the terminal works which isn't entirely compatible with 34 | iex. 35 | 36 | ```elixir 37 | Mix.install([{:termite, "~> 0.3.0"}]) 38 | 39 | styled = 40 | Termite.Style.bold() 41 | |> Termite.Style.foreground(3) 42 | |> Termite.Style.background(5) 43 | |> Termite.Style.render_to_string("I am bold\n") 44 | 45 | Termite.Terminal.start() 46 | |> Termite.Screen.clear_screen() 47 | |> Termite.Screen.cursor_position(0, 0) 48 | |> Termite.Screen.write(styled) 49 | |> tap(fn _ -> :timer.sleep(1000) end) 50 | |> Termite.Screen.alt_screen() 51 | |> Termite.Screen.cursor_position(10, 3) 52 | |> Termite.Screen.write("I'm on an alt screen") 53 | |> tap(fn _ -> :timer.sleep(1000) end) 54 | |> Termite.Screen.exit_alt_screen() 55 | 56 | ``` 57 | 58 | terminal 59 | 60 | More examples are available in the examples directory. 61 | 62 | ## Documentation 63 | 64 | https://hexdocs.pm/termite 65 | 66 | Documentation can be generated with ExDoc using: 67 | 68 | ```sh 69 | mix docs 70 | ``` 71 | -------------------------------------------------------------------------------- /examples/colors.exs: -------------------------------------------------------------------------------- 1 | defmodule Colors do 2 | def start() do 3 | Termite.Terminal.start() 4 | |> draw() 5 | end 6 | 7 | defp newline(str, i, i), do: str <> "\n" 8 | defp newline(str, _i, _limit), do: str 9 | 10 | defp color_block(foreground, background, padding \\ 3) do 11 | Termite.Style.ansi256() 12 | |> Termite.Style.background(background) 13 | |> Termite.Style.foreground(foreground) 14 | |> Termite.Style.render_to_string( 15 | " #{String.pad_leading(to_string(background), padding, " ")} " 16 | ) 17 | end 18 | 19 | def draw(term) do 20 | str = Termite.Style.bold() |> Termite.Style.render_to_string("Basic ANSI Colors\n") 21 | term = Termite.Screen.write(term, str) 22 | 23 | term = 24 | Enum.reduce(0..15, term, fn i, acc -> 25 | foreground = if i < 5, do: 7, else: 0 26 | str = color_block(foreground, i, 2) 27 | Termite.Screen.write(acc, newline(str, i, 7)) 28 | end) 29 | 30 | str = Termite.Style.bold() |> Termite.Style.render_to_string("\n\nExtended ANSI Colors\n") 31 | term = Termite.Screen.write(term, str) 32 | 33 | term = 34 | Enum.reduce(16..231, term, fn i, acc -> 35 | foreground = if i < 28, do: 7, else: 0 36 | str = color_block(foreground, i) 37 | Termite.Screen.write(acc, newline(str, rem(i - 16, 6), 5)) 38 | end) 39 | 40 | str = Termite.Style.bold() |> Termite.Style.render_to_string("\n\Extended ANSI Grayscale\n") 41 | term = Termite.Screen.write(term, str) 42 | 43 | term = 44 | Enum.reduce(232..255, term, fn i, acc -> 45 | foreground = if i < 244, do: 7, else: 0 46 | str = color_block(foreground, i) 47 | Termite.Screen.write(acc, newline(str, rem(i - 232, 6), 5)) 48 | end) 49 | 50 | Termite.Screen.write(term, "\n") 51 | end 52 | end 53 | 54 | Colors.start() 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 3 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 4 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/termite/style_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Termite.StyleTest do 2 | use ExUnit.Case, async: true 3 | doctest Termite.Style 4 | 5 | describe "sequences" do 6 | test "sequences are combined" do 7 | style = 8 | Termite.Style.bold() 9 | |> Termite.Style.italic() 10 | |> Termite.Style.foreground(5) 11 | |> Termite.Style.background(3) 12 | 13 | assert style.styles == [:bold, :italic, {:foreground, 5}, {:background, 3}] 14 | end 15 | 16 | test "sequences rendered to a string" do 17 | string = 18 | Termite.Style.bold() 19 | |> Termite.Style.italic() 20 | |> Termite.Style.blink() 21 | |> Termite.Style.render_to_string("hello world") 22 | 23 | assert string == "\e[5;1;3mhello world\e[0m" 24 | end 25 | 26 | test "no escape codes if there are no styles" do 27 | string = Termite.Style.render_to_string("hello world") 28 | 29 | assert string == "hello world" 30 | end 31 | end 32 | 33 | describe "colors" do 34 | test "ansi colors are supported by default" do 35 | string = 36 | Termite.Style.foreground(5) 37 | |> Termite.Style.background(3) 38 | |> Termite.Style.render_to_string("hello world") 39 | 40 | assert string == "\e[43;35mhello world\e[0m" 41 | end 42 | 43 | test "bright ansi colors are supported" do 44 | string = 45 | Termite.Style.foreground(11) 46 | |> Termite.Style.background(13) 47 | |> Termite.Style.render_to_string("hello world") 48 | 49 | assert string == "\e[105;93mhello world\e[0m" 50 | end 51 | 52 | test "extended ansi colors are supported if specified" do 53 | string = 54 | Termite.Style.ansi256() 55 | |> Termite.Style.foreground(50) 56 | |> Termite.Style.background(33) 57 | |> Termite.Style.render_to_string("hello world") 58 | 59 | assert string == "\e[48;5;33;38;5;50mhello world\e[0m" 60 | end 61 | 62 | test "extended ansi colors raise by default" do 63 | assert_raise(FunctionClauseError, fn -> 64 | Termite.Style.foreground(50) 65 | |> Termite.Style.background(33) 66 | |> Termite.Style.render_to_string("hello world") 67 | end) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /examples/demo.exs: -------------------------------------------------------------------------------- 1 | defmodule Demo do 2 | alias Termite.Screen 3 | 4 | def start() do 5 | Termite.Terminal.start() 6 | |> Screen.run_escape_sequence(:screen_alt) 7 | |> Screen.title("Termite Demo") 8 | |> Screen.progress(:paused, 20) 9 | |> redraw_and_loop() 10 | end 11 | 12 | defp redraw_and_loop(state) do 13 | state |> redraw() |> loop() 14 | end 15 | 16 | def loop(state) do 17 | case Termite.Terminal.poll(state) do 18 | {:signal, :winch} -> redraw_and_loop(Termite.Terminal.resize(state)) 19 | {:data, "\e[A"} -> state |> Screen.run_escape_sequence(:cursor_up, [1]) |> loop() 20 | {:data, "\e[B"} -> state |> Screen.run_escape_sequence(:cursor_down, [1]) |> loop() 21 | {:data, "\e[C"} -> state |> Screen.run_escape_sequence(:cursor_forward, [1]) |> loop() 22 | {:data, "\e[D"} -> state |> Screen.run_escape_sequence(:cursor_back, [1]) |> loop() 23 | {:data, "q"} -> cleanup_and_exit(state) 24 | {:data, "r"} -> redraw_and_loop(state) 25 | _ -> loop(state) 26 | end 27 | end 28 | 29 | defp cleanup(state) do 30 | state 31 | |> Screen.run_escape_sequence(:screen_alt_exit) 32 | |> Screen.run_escape_sequence(:screen_clear) 33 | |> Screen.progress(:clear) 34 | end 35 | 36 | defp panel(state, str) do 37 | state = Screen.write(state, "┌" <> String.duplicate("─", state.size.width - 2) <> "┐") 38 | 39 | state = 40 | Enum.reduce(1..(state.size.height - 2), state, fn _, state -> 41 | Screen.write(state, "│" <> String.duplicate(" ", state.size.width - 2) <> "│") 42 | end) 43 | 44 | state = Screen.write(state, "└" <> String.duplicate("─", state.size.width - 2) <> "┘") 45 | 46 | # We have to move down and then up again to correctly reset the cursor 47 | state 48 | |> Screen.run_escape_sequence(:cursor_move, [3, 0]) 49 | |> Screen.write(str) 50 | |> Screen.run_escape_sequence(:cursor_move, [3, 3]) 51 | end 52 | 53 | def redraw(state) do 54 | state 55 | |> Screen.run_escape_sequence(:cursor_move, [0, 0]) 56 | |> Screen.run_escape_sequence(:screen_clear) 57 | |> panel("Size: #{state.size.width}x#{state.size.height}") 58 | |> Screen.write("This is a simple demo") 59 | |> Screen.run_escape_sequence(:cursor_next_line, [1]) 60 | |> Screen.run_escape_sequence(:cursor_forward, [2]) 61 | |> Screen.write("Press q to Exit") 62 | end 63 | 64 | defp cleanup_and_exit(state) do 65 | cleanup(state) 66 | :timer.sleep(10) 67 | System.halt() 68 | end 69 | end 70 | 71 | Demo.start() 72 | -------------------------------------------------------------------------------- /lib/termite/terminal/prim_tty.ex: -------------------------------------------------------------------------------- 1 | defmodule Termite.Terminal.PrimTTY do 2 | @moduledoc """ 3 | A termite adapter for prim_tty provided by OTP. 4 | 5 | This adapter is the default used by Termite. 6 | """ 7 | 8 | @behaviour Termite.Terminal.Adapter 9 | 10 | require Logger 11 | require Record 12 | 13 | otp_release = String.to_integer(System.otp_release()) 14 | 15 | # We need to get the minor release, and we want to represent it as a 4-tuple 16 | # for easy comparison. 17 | erts_version = 18 | :erlang.system_info(:version) 19 | |> to_string() 20 | |> String.split(".") 21 | |> Enum.map(&String.to_integer/1) 22 | |> then(&(&1 ++ List.duplicate(0, 4 - length(&1)))) 23 | |> List.to_tuple() 24 | 25 | cond do 26 | otp_release >= 28 -> 27 | Record.defrecordp(:state, Record.extract(:state, from: "include/prim_tty_28_0.hrl")) 28 | 29 | otp_release >= 27 -> 30 | Record.defrecordp(:state, Record.extract(:state, from: "include/prim_tty_27_0.hrl")) 31 | 32 | # 26.2.5.3 changed the record 33 | otp_release >= 26 and erts_version >= {14, 2, 5, 3} -> 34 | Record.defrecordp(:state, Record.extract(:state, from: "include/prim_tty_26_2_5_3.hrl")) 35 | 36 | otp_release >= 26 -> 37 | Record.defrecordp(:state, Record.extract(:state, from: "include/prim_tty_26_0.hrl")) 38 | 39 | true -> 40 | raise "Unsupported OTP version: #{otp_release}. Termite requires OTP 26 or later." 41 | end 42 | 43 | defp from_record(term), do: state(term) 44 | 45 | @doc false 46 | @impl true 47 | def reader(term) do 48 | {_, ref} = from_record(term)[:reader] 49 | {:ok, ref} 50 | end 51 | 52 | defp writer(term) do 53 | from_record(term)[:writer] 54 | end 55 | 56 | @impl true 57 | def resize(_term) do 58 | {:ok, cols} = :io.columns() 59 | {:ok, rows} = :io.rows() 60 | %{width: cols, height: rows} 61 | end 62 | 63 | @doc false 64 | @impl true 65 | def start(opts \\ []) do 66 | opts = Map.new(opts) 67 | :erlang.unregister(:user_drv_writer) 68 | :erlang.unregister(:user_drv_reader) 69 | 70 | old_level = Logger.level() 71 | Logger.configure(level: :emergency) 72 | term = :prim_tty.init(opts) 73 | :timer.sleep(100) 74 | Logger.configure(level: old_level) 75 | {:ok, term} 76 | end 77 | 78 | @doc false 79 | @impl true 80 | def write(term, str) do 81 | term = state(term, xn: false) 82 | {output, term} = :prim_tty.handle_request(term, {:putc, str}) 83 | {_pid, ref} = writer(term) 84 | :prim_tty.write(term, output, self()) 85 | 86 | receive do 87 | {^ref, :ok} -> nil 88 | end 89 | 90 | {:ok, term} 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/termite/style.ex: -------------------------------------------------------------------------------- 1 | defmodule Termite.Style do 2 | @moduledoc """ 3 | This module contains functions for building up a styled input. 4 | 5 | The functions in this module are intended to be chained. For example: 6 | 7 | ```elixir 8 | iex> Termite.Style.background(5) 9 | ...> |> Termite.Style.foreground(3) 10 | ...> |> Termite.Style.bold() 11 | ...> |> Termite.Style.render_to_string("Hello World") 12 | "\e[1;45;33mHello World\e[0m" 13 | ``` 14 | """ 15 | 16 | alias __MODULE__ 17 | 18 | defstruct styles: [], type: :ansi 19 | 20 | defp seq(:reset, _), do: "0" 21 | defp seq(:bold, _), do: "1" 22 | defp seq(:faint, _), do: "2" 23 | defp seq(:italic, _), do: "3" 24 | defp seq(:underline, _), do: "4" 25 | defp seq(:blink, _), do: "5" 26 | defp seq(:inverse, _), do: "7" 27 | defp seq(:crossed_out, _), do: "9" 28 | defp seq({:background, color}, style), do: color(style, color, :background) 29 | defp seq({:foreground, color}, style), do: color(style, color, :foreground) 30 | 31 | defp color(%Style{type: :ansi}, color, type) when color < 16 do 32 | color = color + if color < 8, do: 30, else: 82 33 | color + if type == :background, do: 10, else: 0 34 | end 35 | 36 | defp color(%Style{type: :ansi256}, color, :background) do 37 | "48;5;#{color}" 38 | end 39 | 40 | defp color(%Style{type: :ansi256}, color, :foreground) do 41 | "38;5;#{color}" 42 | end 43 | 44 | @doc """ 45 | Change the style type to ansi. This is the default value. 46 | """ 47 | def ansi(style \\ %Style{}) do 48 | %{style | type: :ansi} 49 | end 50 | 51 | @doc """ 52 | Change the style type to ansi256 for extended colors. 53 | """ 54 | def ansi256(style \\ %Style{}) do 55 | %{style | type: :ansi256} 56 | end 57 | 58 | @doc """ 59 | Set the background color. This can be a value up to 16 for `:ansi` 60 | or up to 255 for `:ansi256`. 61 | """ 62 | def background(%{styles: styles} = style \\ %Style{}, color) do 63 | %{style | styles: styles ++ [{:background, color}]} 64 | end 65 | 66 | @doc """ 67 | Set the foreground (text) color. This can be a value up to 16 for `:ansi` 68 | or up to 255 for `:ansi256`. 69 | """ 70 | def foreground(%{styles: styles} = style \\ %Style{}, color) do 71 | %{style | styles: styles ++ [{:foreground, color}]} 72 | end 73 | 74 | @doc """ 75 | Set the text style to bold. 76 | """ 77 | def bold(%{styles: styles} = style \\ %Style{}) do 78 | %{style | styles: styles ++ [:bold]} 79 | end 80 | 81 | @doc """ 82 | Set the text style to dim/faint. 83 | """ 84 | def faint(%{styles: styles} = style \\ %Style{}) do 85 | %{style | styles: styles ++ [:faint]} 86 | end 87 | 88 | @doc """ 89 | Set the text style to underline. 90 | """ 91 | def underline(%{styles: styles} = style \\ %Style{}) do 92 | %{style | styles: styles ++ [:underline]} 93 | end 94 | 95 | @doc """ 96 | Set the text style to blink. 97 | """ 98 | def blink(%{styles: styles} = style \\ %Style{}) do 99 | %{style | styles: styles ++ [:blink]} 100 | end 101 | 102 | @doc """ 103 | Set the text style to italic. 104 | """ 105 | def italic(%{styles: styles} = style \\ %Style{}) do 106 | %{style | styles: styles ++ [:italic]} 107 | end 108 | 109 | @doc """ 110 | Set the text style to inverse (swap foreground/background colors). 111 | """ 112 | def inverse(%{styles: styles} = style \\ %Style{}) do 113 | %{style | styles: styles ++ [:inverse]} 114 | end 115 | 116 | @doc """ 117 | Set the text style to inverse (swap foreground/background colors). 118 | """ 119 | def reverse(%{styles: styles} = style \\ %Style{}) do 120 | %{style | styles: styles ++ [:inverse]} 121 | end 122 | 123 | @doc """ 124 | Set the text style to strikethrough. 125 | """ 126 | def crossed_out(%{styles: styles} = style \\ %Style{}) do 127 | %{style | styles: styles ++ [:crossed_out]} 128 | end 129 | 130 | @doc """ 131 | Output the reset code. 132 | 133 | ```elixir 134 | iex> Termite.Style.reset_code() 135 | "\e[0m" 136 | ``` 137 | """ 138 | def reset_code() do 139 | Termite.Screen.escape_code() <> seq(:reset, %Style{}) <> "m" 140 | end 141 | 142 | @doc """ 143 | Render a string with the specified styles. And a reset code. 144 | 145 | ```elixir 146 | iex> Termite.Style.bold() 147 | ...> |> Termite.Style.render_to_string("Hello") 148 | "\e[1mHello\e[0m" 149 | ``` 150 | """ 151 | def render_to_string(style \\ %Style{}, str) 152 | 153 | def render_to_string(%Style{styles: []}, str) do 154 | str 155 | end 156 | 157 | def render_to_string(style = %Style{}, str) do 158 | seq = 159 | style.styles 160 | |> Enum.sort() 161 | |> Enum.map(&seq(&1, style)) 162 | |> Enum.join(";") 163 | 164 | Termite.Screen.escape_code() <> 165 | seq <> "m" <> str <> reset_code() 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /examples/snake.exs: -------------------------------------------------------------------------------- 1 | defmodule Snake do 2 | require Logger 3 | alias Termite.Screen 4 | 5 | def start(terminal) do 6 | term = 7 | terminal 8 | |> Screen.run_escape_sequence(:screen_alt) 9 | |> Screen.run_escape_sequence(:cursor_hide) 10 | 11 | %{ 12 | term: term, 13 | size: %{width: 25, height: 15}, 14 | direction: :right, 15 | path: [{7, 10}, {8, 10}, {9, 10}, {10, 10}], 16 | food: nil 17 | } 18 | |> generate_food() 19 | |> redraw_and_loop() 20 | end 21 | 22 | defp generate_food(state) do 23 | x = :rand.uniform(state.size.width - 2) + 1 24 | y = :rand.uniform(state.size.height - 2) + 1 25 | 26 | if {x, y} in state.path do 27 | generate_food(state) 28 | else 29 | %{state | food: {x, y}} 30 | end 31 | end 32 | 33 | defp redraw_and_loop(state) do 34 | state |> redraw() |> loop() 35 | end 36 | 37 | def loop(%{term: term} = state) do 38 | state = draw_snake(state) 39 | 40 | case Termite.Terminal.poll(term, 100) do 41 | {:signal, :winch} -> redraw_and_loop(Termite.Terminal.resize(term)) 42 | {:data, "\e[A"} -> change_direction(state, :up) |> loop() 43 | {:data, "\e[B"} -> change_direction(state, :down) |> loop() 44 | {:data, "\e[C"} -> change_direction(state, :right) |> loop() 45 | {:data, "\e[D"} -> change_direction(state, :left) |> loop() 46 | {:data, "q"} -> cleanup_and_exit(term) 47 | :timeout -> loop(state) 48 | _ -> loop(state) 49 | end 50 | end 51 | 52 | defp change_direction(%{direction: :left} = state, dir) when dir in [:left, :right], do: state 53 | defp change_direction(%{direction: :right} = state, dir) when dir in [:left, :right], do: state 54 | defp change_direction(%{direction: :up} = state, dir) when dir in [:up, :down], do: state 55 | defp change_direction(%{direction: :down} = state, dir) when dir in [:up, :down], do: state 56 | defp change_direction(state, dir), do: %{state | direction: dir} 57 | 58 | defp cleanup(state) do 59 | state 60 | |> Screen.run_escape_sequence(:cursor_show) 61 | |> Screen.run_escape_sequence(:screen_alt_exit) 62 | |> Screen.run_escape_sequence(:screen_clear) 63 | end 64 | 65 | defp game_panel(term, state) do 66 | term = 67 | term 68 | |> Screen.write("┌" <> String.duplicate("─", state.size.width * 2 - 2) <> "┐") 69 | |> Screen.run_escape_sequence(:cursor_next_line, [1]) 70 | 71 | term = 72 | Enum.reduce(1..(state.size.height - 2), term, fn _, term -> 73 | term 74 | |> Screen.write("│" <> String.duplicate(" ", state.size.width * 2 - 2) <> "│") 75 | |> Screen.run_escape_sequence(:cursor_next_line, [1]) 76 | end) 77 | 78 | term 79 | |> Screen.write("└" <> String.duplicate("─", state.size.width * 2 - 2)) 80 | |> Screen.write("┘") 81 | end 82 | 83 | def redraw(state) do 84 | term = 85 | state.term 86 | |> Screen.run_escape_sequence(:cursor_move, [0, 0]) 87 | |> Screen.run_escape_sequence(:screen_clear) 88 | |> game_panel(state) 89 | 90 | %{state | term: term} 91 | |> draw_snake() 92 | end 93 | 94 | defp draw_food(state) do 95 | {food_x, food_y} = state.food 96 | 97 | term = 98 | state.term 99 | |> Screen.run_escape_sequence(:cursor_move, [food_x * 2, food_y]) 100 | |> Screen.write("*") 101 | 102 | %{state | term: term} 103 | end 104 | 105 | defp draw_snake(state) do 106 | %{term: term, path: path, direction: direction} = state 107 | {cur_x, cur_y} = Enum.reverse(path) |> hd() 108 | {tail_x, tail_y} = hd(path) 109 | 110 | term = 111 | term 112 | |> Screen.run_escape_sequence(:cursor_move, [tail_x * 2, tail_y]) 113 | |> Screen.write(" ") 114 | 115 | {new_x, new_y} = 116 | new_point = 117 | case direction do 118 | :right -> {cur_x + 1, cur_y} 119 | :left -> {cur_x - 1, cur_y} 120 | :up -> {cur_x, cur_y - 1} 121 | :down -> {cur_x, cur_y + 1} 122 | end 123 | 124 | wall_collision? = 125 | new_x == 0 || new_x == state.size.width || new_y == 1 || new_y == state.size.height 126 | 127 | tail_collision? = new_point in state.path 128 | 129 | state = 130 | cond do 131 | new_point == state.food -> generate_food(%{state | path: path ++ [new_point]}) 132 | wall_collision? || tail_collision? -> cleanup_and_exit(term) 133 | true -> %{state | path: tl(path) ++ [new_point]} 134 | end 135 | 136 | term = Screen.write(term, IO.ANSI.inverse()) 137 | 138 | term = 139 | Enum.reduce(state.path, term, fn {x, y}, term -> 140 | term 141 | |> Screen.run_escape_sequence(:cursor_move, [x * 2, y]) 142 | |> Screen.write(" ") 143 | end) 144 | 145 | term = Screen.write(term, IO.ANSI.reset()) 146 | 147 | draw_food(%{state | term: term}) 148 | end 149 | 150 | defp cleanup_and_exit(state) do 151 | cleanup(state) 152 | :timer.sleep(10) 153 | System.halt() 154 | end 155 | end 156 | 157 | terminal = Termite.Terminal.start() 158 | Snake.start(terminal) 159 | -------------------------------------------------------------------------------- /lib/termite/screen.ex: -------------------------------------------------------------------------------- 1 | defmodule Termite.Screen do 2 | @moduledoc """ 3 | This module handles terminal related escape sequences. 4 | See the source for valid escape sequences. 5 | """ 6 | 7 | defp seq(:cursor_up, [n]), do: "#{n}A" 8 | defp seq(:cursor_down, [n]), do: "#{n}B" 9 | defp seq(:cursor_forward, [n]), do: "#{n}C" 10 | defp seq(:cursor_back, [n]), do: "#{n}D" 11 | defp seq(:cursor_next_line, [n]), do: "#{n}E" 12 | defp seq(:cursor_previous_line, [n]), do: "#{n}F" 13 | defp seq(:cursor_move, [y, x]), do: "#{x};#{y}H" 14 | 15 | defp seq(:screen_clear, []), do: "2J" 16 | 17 | defp seq(:screen_alt, []), do: "?1049h" 18 | defp seq(:screen_alt_exit, []), do: "?1049l" 19 | 20 | defp seq(:delete_chars, [n]), do: "#{n}P" 21 | 22 | defp seq(:cursor_show, []), do: "?25h" 23 | defp seq(:cursor_hide, []), do: "?25l" 24 | 25 | defp osc_seq(:title, [title]), do: "0;#{title}" 26 | defp osc_seq(:progress, [state, percent]), do: "9;4;#{state};#{percent}" 27 | 28 | @doc """ 29 | Return the escape code for the terminal. 30 | 31 | ```elixir 32 | iex> Termite.Screen.escape_code() 33 | "\e[" 34 | ``` 35 | """ 36 | def escape_code() do 37 | "\x1b[" 38 | end 39 | 40 | @doc """ 41 | Return an escape sequence. 42 | 43 | ```elixir 44 | iex> Termite.Screen.escape_sequence(:cursor_back, [3]) 45 | "\e[3D" 46 | ``` 47 | """ 48 | def escape_sequence(command, args \\ []) do 49 | escape_code() <> seq(command, args) 50 | end 51 | 52 | def osc_escape_sequence(command, args \\ []) do 53 | "\e]" <> osc_seq(command, args) 54 | end 55 | 56 | @doc """ 57 | Write an escape sequence to the terminal. 58 | """ 59 | def run_escape_sequence(term, command, args \\ []) do 60 | write(term, escape_sequence(command, args)) 61 | end 62 | 63 | @doc """ 64 | Move the cursor up by n lines. 65 | """ 66 | def cursor_up(term, n \\ 1) do 67 | run_escape_sequence(term, :cursor_up, [n]) 68 | end 69 | 70 | @doc """ 71 | Move the cursor down by n lines. 72 | """ 73 | def cursor_down(term, n \\ 1) do 74 | run_escape_sequence(term, :cursor_down, [n]) 75 | end 76 | 77 | @doc """ 78 | Move the cursor forward by n cells. 79 | """ 80 | def cursor_forward(term, n \\ 1) do 81 | run_escape_sequence(term, :cursor_forward, [n]) 82 | end 83 | 84 | @doc """ 85 | Move the cursor back by n cells. 86 | """ 87 | def cursor_back(term, n \\ 1) do 88 | run_escape_sequence(term, :cursor_back, [n]) 89 | end 90 | 91 | @doc """ 92 | Move the cursor down by n lines and place cursor at the beginning of the line. 93 | """ 94 | def cursor_next_line(term, n \\ 1) do 95 | run_escape_sequence(term, :cursor_next_line, [n]) 96 | end 97 | 98 | @doc """ 99 | Move the cursor up by n lines and place cursor at the beginning of the line. 100 | """ 101 | def cursor_previous_line(term, n \\ 1) do 102 | run_escape_sequence(term, :cursor_previous_line, [n]) 103 | end 104 | 105 | @doc """ 106 | Set the cursor position to x,y. 107 | """ 108 | def cursor_position(term, x, y) do 109 | run_escape_sequence(term, :cursor_move, [x, y]) 110 | end 111 | 112 | @doc """ 113 | Show the cursor. 114 | """ 115 | def show_cursor(term) do 116 | run_escape_sequence(term, :cursor_show, []) 117 | end 118 | 119 | @doc """ 120 | Hide the cursor. 121 | """ 122 | def hide_cursor(term) do 123 | run_escape_sequence(term, :cursor_hide, []) 124 | end 125 | 126 | @doc """ 127 | Clears the screen. 128 | """ 129 | def clear_screen(term) do 130 | run_escape_sequence(term, :screen_clear, []) 131 | end 132 | 133 | @doc """ 134 | Delete the specified number of characters from the cursor. 135 | """ 136 | def delete_chars(term, n \\ 1) do 137 | run_escape_sequence(term, :delete_chars, [n]) 138 | end 139 | 140 | @doc """ 141 | Switch to alt screen. 142 | """ 143 | def alt_screen(term) do 144 | run_escape_sequence(term, :screen_alt, []) 145 | end 146 | 147 | @doc """ 148 | Exit to alt screen. 149 | """ 150 | def exit_alt_screen(term) do 151 | run_escape_sequence(term, :screen_alt_exit, []) 152 | end 153 | 154 | @doc """ 155 | Alters the terminal tab or window title. OSC Compatible terminals only. 156 | """ 157 | def title(term, title) do 158 | write(term, osc_escape_sequence(:title, [title])) 159 | end 160 | 161 | @doc """ 162 | Support for OSC Progress Bars https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC 163 | `:clear` - Clear the progress bar 164 | `:info` - Information state (blue) 165 | `:error` - Error state (red) 166 | `:intermediate` - Intermediate state (yellow) 167 | `:paused` - Paused state (orange) 168 | 169 | progress - Percentage of progress (0-100) 170 | """ 171 | def progress(term, :clear) do 172 | write(term, osc_escape_sequence(:progress, [0, 0])) 173 | end 174 | 175 | def progress(term, :info, progress) do 176 | write(term, osc_escape_sequence(:progress, [1, progress])) 177 | end 178 | 179 | def progress(term, :error, progress) do 180 | write(term, osc_escape_sequence(:progress, [2, progress])) 181 | end 182 | 183 | def progress(term, :intermediate, progress) do 184 | write(term, osc_escape_sequence(:progress, [3, progress])) 185 | end 186 | 187 | def progress(term, :paused, progress) do 188 | write(term, osc_escape_sequence(:progress, [4, progress])) 189 | end 190 | 191 | defdelegate write(term, str), to: Termite.Terminal 192 | end 193 | --------------------------------------------------------------------------------