├── src ├── teashop │ ├── internal │ │ ├── internal_command.gleam │ │ ├── reader_state.gleam │ │ ├── renderer_options.gleam │ │ └── action.gleam │ ├── event.gleam │ ├── key.gleam │ ├── options.gleam │ ├── duration.gleam │ └── command.gleam ├── input.ts ├── teashop.gleam ├── input_reader_types.ts ├── input_reader_mod.ts ├── event_emitter.ts ├── input_reader_decoders_keyboard.mjs ├── input_reader_decoders_keyboard.ts ├── input_reader_decoders_mouse.ts ├── keypress.mjs └── teashop.ffi.mjs ├── .gitignore ├── examples ├── .gitignore ├── .github │ └── workflows │ │ └── test.yml ├── gleam.toml ├── README.md ├── manifest.toml └── src │ └── shop_demo.gleam ├── test └── teashop_test.gleam ├── gleam.toml ├── .github └── workflows │ └── test.yml ├── manifest.toml └── README.md /src/teashop/internal/internal_command.gleam: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | -------------------------------------------------------------------------------- /src/teashop/internal/reader_state.gleam: -------------------------------------------------------------------------------- 1 | pub type ReaderState { 2 | Reading 3 | Canceled 4 | } 5 | -------------------------------------------------------------------------------- /src/teashop/internal/renderer_options.gleam: -------------------------------------------------------------------------------- 1 | 2 | pub type AltScreenState { 3 | AltScreenEnabled 4 | AltScreenDisabled 5 | } 6 | -------------------------------------------------------------------------------- /src/teashop/event.gleam: -------------------------------------------------------------------------------- 1 | import teashop/key 2 | 3 | pub type Event(msg) { 4 | Key(key.Key) 5 | Resize(width: Int, height: Int) 6 | Custom(msg) 7 | } 8 | -------------------------------------------------------------------------------- /src/teashop/internal/action.gleam: -------------------------------------------------------------------------------- 1 | pub type InternalAction(msg) { 2 | Shutdown 3 | Send(msg) 4 | WindowTitle(String) 5 | ReleaseTerminal 6 | RestoreTerminal 7 | } 8 | -------------------------------------------------------------------------------- /test/teashop_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | import { emitInputEvents } from "./input_reader_mod.ts"; 3 | 4 | /** Emit input events to Tui */ 5 | export async function handleInput(tui) { 6 | await emitInputEvents(tui.stdin, tui, tui.refreshRate); 7 | } 8 | -------------------------------------------------------------------------------- /src/teashop/key.gleam: -------------------------------------------------------------------------------- 1 | pub type Key { 2 | Backspace 3 | Left 4 | Right 5 | Up 6 | Down 7 | Home 8 | End 9 | PageUp 10 | PageDown 11 | Tab 12 | Delete 13 | Insert 14 | Enter 15 | Space 16 | FKey(Int) 17 | Char(String) 18 | Alt(Key) 19 | Ctrl(Key) 20 | Shift(Key) 21 | Esc 22 | Unknown 23 | } 24 | -------------------------------------------------------------------------------- /src/teashop/options.gleam: -------------------------------------------------------------------------------- 1 | import teashop 2 | 3 | @external(javascript, "../teashop.ffi.mjs", "set_refresh_delay") 4 | pub fn refresh_delay(app: teashop.App(model, msg, flags), refresh_delay: Int) -> teashop.App(model, msg, flags) 5 | 6 | @external(javascript, "../teashop.ffi.mjs", "set_alt_screen") 7 | pub fn with_alt_screen(app: teashop.App(model, msg, flags)) -> teashop.App(model, msg, flags) 8 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "teashop" 2 | version = "1.0.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | # description = "" 8 | # licences = ["Apache-2.0"] 9 | # repository = { type = "github", user = "username", repo = "project" } 10 | # links = [{ title = "Website", href = "https://gleam.run" }] 11 | 12 | target = "javascript" 13 | 14 | [dependencies] 15 | gleam_stdlib = "~> 0.32" 16 | 17 | [dev-dependencies] 18 | gleeunit = "~> 1.0" 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "0.33.0" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /examples/.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "0.34.0-dev" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /examples/gleam.toml: -------------------------------------------------------------------------------- 1 | name = "examples" 2 | version = "1.0.0" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | # 7 | # description = "" 8 | # licences = ["Apache-2.0"] 9 | # repository = { type = "github", user = "username", repo = "project" } 10 | # links = [{ title = "Website", href = "https://gleam.run" }] 11 | 12 | [dependencies] 13 | gleam_stdlib = "~> 0.34 or ~> 1.0" 14 | teashop = { path = ".." } 15 | shellout = "~> 1.5" 16 | gleam_javascript = "~> 0.7" 17 | 18 | [dev-dependencies] 19 | gleeunit = "~> 1.0" 20 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # examples 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/examples)](https://hex.pm/packages/examples) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/examples/) 5 | 6 | ## Quick start 7 | 8 | ```sh 9 | gleam run # Run the project 10 | gleam test # Run the tests 11 | gleam shell # Run an Erlang shell 12 | ``` 13 | 14 | ## Installation 15 | 16 | If available on Hex this package can be added to your Gleam project: 17 | 18 | ```sh 19 | gleam add examples 20 | ``` 21 | 22 | and its documentation can be found at . 23 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, 6 | { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 7 | ] 8 | 9 | [requirements] 10 | gleam_stdlib = { version = "~> 0.32" } 11 | gleeunit = { version = "~> 1.0" } 12 | -------------------------------------------------------------------------------- /src/teashop/duration.gleam: -------------------------------------------------------------------------------- 1 | pub opaque type Duration { 2 | Duration(milliseconds: Int) 3 | } 4 | 5 | const seconds_to_milliseconds = 1000 6 | const minutes_to_milliseconds = 60_000 7 | const hours_to_milliseconds = 3_600_000 8 | 9 | pub fn milliseconds(n: Int) -> Duration { 10 | Duration(n) 11 | } 12 | 13 | pub fn seconds(n: Int) -> Duration { 14 | Duration(n * seconds_to_milliseconds) 15 | } 16 | 17 | pub fn minutes(n: Int) -> Duration { 18 | Duration(n * minutes_to_milliseconds) 19 | } 20 | 21 | pub fn hours(n: Int) -> Duration { 22 | Duration(n * hours_to_milliseconds) 23 | } 24 | 25 | pub fn add(a: Duration, b: Duration) -> Duration { 26 | Duration(a.milliseconds + b.milliseconds) 27 | } 28 | 29 | pub fn to_milliseconds(a: Duration) -> Int { 30 | a.milliseconds 31 | } 32 | -------------------------------------------------------------------------------- /src/teashop.gleam: -------------------------------------------------------------------------------- 1 | import teashop/command 2 | import teashop/event 3 | import teashop/internal/action 4 | 5 | pub opaque type App(model, msg, flags) 6 | 7 | pub type Dispatch(msg) = 8 | fn(Action(msg)) -> Nil 9 | 10 | pub opaque type Action(msg) { 11 | Action(action.InternalAction(msg)) 12 | } 13 | 14 | @external(javascript, "./teashop.ffi.mjs", "setup") 15 | pub fn app( 16 | init: fn(flags) -> #(model, command.Command(msg)), 17 | update: fn(model, event.Event(msg)) -> #(model, command.Command(msg)), 18 | view: fn(model) -> String, 19 | ) -> App(model, msg, flags) 20 | 21 | @external(javascript, "./teashop.ffi.mjs", "run") 22 | pub fn start( 23 | app: App(model, msg, flags), 24 | flags: flags, 25 | ) -> fn(Action(msg)) -> Nil 26 | 27 | 28 | pub fn send(dispatch: Dispatch(msg), msg: msg) { 29 | dispatch(Action(action.Send(msg))) 30 | } 31 | 32 | pub fn quit(dispatch: Dispatch(msg)) { 33 | dispatch(Action(action.Shutdown)) 34 | } 35 | 36 | pub fn set_window_title(dispatch: Dispatch(msg), title: String) { 37 | dispatch(Action(action.WindowTitle(title))) 38 | } 39 | -------------------------------------------------------------------------------- /src/teashop/command.gleam: -------------------------------------------------------------------------------- 1 | import teashop/duration 2 | 3 | pub type InternalCommand(msg) { 4 | Noop 5 | Quit 6 | HideCursor 7 | ShowCursor 8 | ExitAltScreen 9 | EnterAltScreen 10 | ClearScreen 11 | SetWindowTitle(String) 12 | Seq(List(Command(msg))) 13 | SetTimer(msg, duration.Duration) 14 | ExecuteProcess(String, List(String)) 15 | Custom(fn(fn(msg) -> Nil) -> Nil) 16 | } 17 | 18 | pub opaque type Command(msg) { 19 | Command(InternalCommand(msg)) 20 | } 21 | 22 | pub fn from(effect: fn(fn(msg) -> Nil) -> Nil) -> Command(msg) { 23 | Command(Custom(fn(dispatch) { effect(dispatch) })) 24 | } 25 | 26 | pub fn none() { 27 | Command(Noop) 28 | } 29 | 30 | pub fn quit() { 31 | Command(Quit) 32 | } 33 | 34 | pub fn hide_cursor() { 35 | Command(HideCursor) 36 | } 37 | 38 | pub fn show_cursor() { 39 | Command(ShowCursor) 40 | } 41 | 42 | pub fn enter_alt_screen() { 43 | Command(EnterAltScreen) 44 | } 45 | 46 | pub fn exit_alt_screen() { 47 | Command(ExitAltScreen) 48 | } 49 | 50 | pub fn set_timer(msg: msg, duration: duration.Duration) -> Command(msg) { 51 | Command(SetTimer(msg, duration)) 52 | } 53 | 54 | pub fn sequence(list: List(Command(msg))) -> Command(msg) { 55 | Command(Seq(list)) 56 | } 57 | 58 | pub fn set_window_title(title: String) -> Command(msg) { 59 | Command(SetWindowTitle(title)) 60 | } 61 | 62 | pub fn clear_screen() { 63 | Command(ClearScreen) 64 | } 65 | 66 | pub fn execute_process(program, args) { 67 | Command(ExecuteProcess(program, args)) 68 | } 69 | -------------------------------------------------------------------------------- /examples/manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "gleam_erlang", version = "0.24.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "26BDB52E61889F56A291CB34167315780EE4AA20961917314446542C90D1C1A0" }, 6 | { name = "gleam_javascript", version = "0.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "B5E05F479C52217C02BA2E8FC650A716BFB62D4F8D20A90909C908598E12FBE0" }, 7 | { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, 8 | { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 9 | { name = "shellout", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "7B5DE499DBB3DDC25051FC1BB3770DD5466938B6A2AFA91A6FB4A4D49F4CB0D4" }, 10 | { name = "teashop", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], source = "local", path = ".." }, 11 | ] 12 | 13 | [requirements] 14 | gleam_javascript = { version = "~> 0.7"} 15 | gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } 16 | gleeunit = { version = "~> 1.0" } 17 | shellout = { version = "~> 1.5" } 18 | teashop = { path = ".." } 19 | -------------------------------------------------------------------------------- /src/input_reader_types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | import { Range } from "../types.ts"; 3 | 4 | /** Interface defining key press issued to stdin */ 5 | export interface KeyPressEvent { 6 | key: Key; 7 | meta: boolean; 8 | ctrl: boolean; 9 | shift: boolean; 10 | buffer: Uint8Array; 11 | } 12 | 13 | /** Interface defining any mouse event issued to stdin */ 14 | export interface MouseEvent { 15 | key: "mouse"; 16 | buffer: Uint8Array; 17 | x: number; 18 | y: number; 19 | movementX: number; 20 | movementY: number; 21 | meta: boolean; 22 | ctrl: boolean; 23 | shift: boolean; 24 | } 25 | 26 | /** Interface defining mouse press issued to stdin */ 27 | export interface MousePressEvent extends MouseEvent { 28 | drag: boolean; 29 | release: boolean; 30 | /** undefined when `release` is true */ 31 | button: 0 | 1 | 2 | undefined; 32 | } 33 | 34 | export interface MouseScrollEvent extends MouseEvent { 35 | drag: boolean; 36 | /** 37 | * - 1 – Scrolls downwards 38 | * - 0 – Doesn't scroll 39 | * - -1 – Scrolls upwards 40 | */ 41 | scroll: 1 | 0 | -1; 42 | } 43 | 44 | export type Key = 45 | | Alphabet 46 | | Chars 47 | | SpecialKeys 48 | | `${Range<0, 10>}` 49 | | `f${Range<1, 12>}`; 50 | 51 | /** Type defining letters from the latin alphabet */ 52 | export type Alphabet = 53 | | "a" 54 | | "b" 55 | | "c" 56 | | "d" 57 | | "e" 58 | | "f" 59 | | "g" 60 | | "h" 61 | | "i" 62 | | "j" 63 | | "k" 64 | | "l" 65 | | "m" 66 | | "n" 67 | | "o" 68 | | "p" 69 | | "q" 70 | | "r" 71 | | "s" 72 | | "t" 73 | | "u" 74 | | "v" 75 | | "w" 76 | | "x" 77 | | "y" 78 | | "z"; 79 | 80 | /** Type defining special keys */ 81 | export type SpecialKeys = 82 | | "return" 83 | | "tab" 84 | | "backspace" 85 | | "escape" 86 | | "space" 87 | | "up" 88 | | "down" 89 | | "left" 90 | | "right" 91 | | "clear" 92 | | "insert" 93 | | "delete" 94 | | "pageup" 95 | | "pagedown" 96 | | "home" 97 | | "end" 98 | | "tab"; 99 | 100 | /** Type defining interpunction characters */ 101 | export type Chars = 102 | | "!" 103 | | "@" 104 | | "#" 105 | | "$" 106 | | "%" 107 | | "^" 108 | | "&" 109 | | "*" 110 | | "(" 111 | | ")" 112 | | "-" 113 | | "_" 114 | | "=" 115 | | "+" 116 | | "[" 117 | | "{" 118 | | "]" 119 | | "}" 120 | | "'" 121 | | '"' 122 | | ";" 123 | | ":" 124 | | "," 125 | | "<" 126 | | "." 127 | | ">" 128 | | "/" 129 | | "?" 130 | | "\\" 131 | | "|"; 132 | -------------------------------------------------------------------------------- /src/input_reader_mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | 3 | import type { 4 | KeyPressEvent, 5 | MouseEvent, 6 | MousePressEvent, 7 | MouseScrollEvent, 8 | } from "./input_reader_types.ts"; 9 | import { 10 | decodeMouseSGR, 11 | decodeMouseVT_UTF8, 12 | } from "./input_reader_decoders_mouse.ts"; 13 | import { decodeKey } from "./input_reader_decoders_keyboard.ts"; 14 | import type { EmitterEvent, EventEmitter } from "./event_emitter.ts"; 15 | 16 | export type InputEventRecord = { 17 | keyPress: EmitterEvent<[KeyPressEvent]>; 18 | mouseEvent: EmitterEvent<[MouseEvent | MousePressEvent | MouseScrollEvent]>; 19 | mousePress: EmitterEvent<[MousePressEvent]>; 20 | mouseScroll: EmitterEvent<[MouseScrollEvent]>; 21 | }; 22 | 23 | type Stdin = typeof Deno.stdin; 24 | 25 | /** 26 | * Read keypresses from given stdin, parse them and emit to given emitter. 27 | */ 28 | export async function emitInputEvents( 29 | stdin: Stdin, 30 | emitter: EventEmitter, 31 | minReadInterval = 1000 / 60, 32 | ) { 33 | try { 34 | stdin.setRaw(true, { cbreak: Deno.build.os !== "windows" }); 35 | } catch { 36 | // omit 37 | } 38 | 39 | const maxbuffer = new Uint8Array(1024); 40 | async function read() { 41 | const size = await stdin.read(maxbuffer); 42 | const buffer = maxbuffer.subarray(0, size ?? 0); 43 | 44 | for (const event of decodeBuffer(buffer)) { 45 | if (event.key === "mouse") { 46 | emitter.emit("mouseEvent", event); 47 | 48 | if ("button" in event) { 49 | emitter.emit("mousePress", event); 50 | } else if ("scroll" in event) { 51 | emitter.emit("mouseScroll", event); 52 | } 53 | } else { 54 | emitter.emit("keyPress", event); 55 | } 56 | } 57 | 58 | setTimeout(read, minReadInterval); 59 | } 60 | await read(); 61 | } 62 | 63 | const textDecoder = new TextDecoder(); 64 | 65 | /** 66 | * Decode character(s) from buffer that was sent to stdin from terminal on mostly 67 | * @see https://invisible-island.net/xterm/ctlseqs/ctlseqs.txt for reference used to create this function 68 | */ 69 | export function* decodeBuffer( 70 | buffer: Uint8Array, 71 | ): Generator< 72 | KeyPressEvent | MouseEvent | MousePressEvent | MouseScrollEvent, 73 | void, 74 | void 75 | > { 76 | const code = textDecoder.decode(buffer); 77 | const lastIndex = code.lastIndexOf("\x1b"); 78 | 79 | if (code.indexOf("\x1b") !== lastIndex) { 80 | yield* decodeBuffer(buffer.subarray(0, lastIndex)); 81 | yield* decodeBuffer(buffer.subarray(lastIndex)); 82 | } else { 83 | yield decodeMouseVT_UTF8(buffer, code) ?? 84 | decodeMouseSGR(buffer, code) ?? 85 | decodeKey(buffer, code); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /examples/src/shop_demo.gleam: -------------------------------------------------------------------------------- 1 | import teashop 2 | import teashop/event 3 | import teashop/command 4 | import teashop/key 5 | import gleam/string 6 | import gleam/list 7 | 8 | pub type Status { 9 | Selected 10 | Unselected 11 | } 12 | 13 | pub type Model { 14 | Model(choices: List(#(String, Status)), cursor: Int) 15 | } 16 | 17 | const initial_model = Model( 18 | cursor: 0, 19 | choices: [ 20 | #("Kitten cuddles 🐈", Unselected), 21 | #("Strawberry shortcake 🍰", Unselected), 22 | #("Blueberry muffins 🫐", Unselected), 23 | ], 24 | ) 25 | 26 | pub fn init(_) { 27 | #(initial_model, command.set_window_title("teashop")) 28 | } 29 | 30 | pub fn update(model: Model, event) { 31 | case event { 32 | event.Key(key.Char("q")) | event.Key(key.Esc) -> #(model, command.quit()) 33 | 34 | event.Key(key.Char("k")) | event.Key(key.Up) -> { 35 | let choices_len = list.length(model.choices) 36 | let cursor = case model.cursor == 0 { 37 | True -> choices_len - 1 38 | False -> model.cursor - 1 39 | } 40 | #(Model(..model, cursor: cursor), command.noop()) 41 | } 42 | 43 | event.Key(key.Char("j")) | event.Key(key.Down) -> { 44 | let choices_len = list.length(model.choices) 45 | let cursor = case model.cursor == { choices_len - 1 } { 46 | True -> 0 47 | False -> model.cursor + 1 48 | } 49 | #(Model(..model, cursor: cursor), command.noop()) 50 | } 51 | 52 | event.Key(key.Enter) | event.Key(key.Space) -> { 53 | let toggle = fn(status) { 54 | case status { 55 | Selected -> Unselected 56 | Unselected -> Selected 57 | } 58 | } 59 | let choices = 60 | list.index_map(model.choices, fn(element, index) { 61 | let #(name, status) = element 62 | let status = case index == model.cursor { 63 | True -> toggle(status) 64 | False -> status 65 | } 66 | #(name, status) 67 | }) 68 | #(Model(..model, choices: choices), command.noop()) 69 | } 70 | _otherwise -> #(model, command.noop()) 71 | } 72 | } 73 | 74 | pub fn view(model: Model) { 75 | let options = 76 | model.choices 77 | |> list.index_map(fn(element, index) { 78 | let #(name, status) = element 79 | let cursor = case model.cursor == index { 80 | True -> ">" 81 | False -> " " 82 | } 83 | let checked = case status { 84 | Selected -> "x" 85 | _ -> " " 86 | } 87 | cursor <> " [" <> checked <> "] " <> name 88 | }) 89 | |> string.join("\n") 90 | 91 | let header = "What should we get at the tea shop?" 92 | let footer = "Press q to quit." 93 | 94 | [header, options, footer] 95 | |> string.join("\n\n") 96 | } 97 | 98 | pub fn main() { 99 | let app = teashop.app(init, update, view) 100 | teashop.start(app, Nil) 101 | } 102 | -------------------------------------------------------------------------------- /src/event_emitter.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | 3 | /** Type for event listener function */ 4 | export type EventListener< 5 | Events extends EventRecord, 6 | Type extends keyof Events = keyof Events, 7 | > = ( 8 | this: EventEmitter, 9 | ...args: Events[Type]["args"] 10 | ) => void | Promise; 11 | 12 | /** 13 | * Type for creating new arguments 14 | * - Required as a workaround for simple tuples and arrays types not working properly 15 | */ 16 | export type EmitterEvent = { 17 | args: Args; 18 | }; 19 | 20 | export type EventRecord = Record; 21 | 22 | /** Custom implementation of event emitter */ 23 | export class EventEmitter { 24 | listeners: { 25 | [key in keyof EventMap]?: EventListener[]; 26 | } = {}; 27 | 28 | /** 29 | * Add new listener for specified event type 30 | * If `once` is set to true it will run just once and then be removed from listeners list 31 | */ 32 | on( 33 | type: Type, 34 | listener: EventListener, 35 | once?: boolean, 36 | ): () => void { 37 | let listeners = this.listeners[type]; 38 | if (!listeners) { 39 | listeners = []; 40 | this.listeners[type] = listeners; 41 | } 42 | 43 | if (once) { 44 | const originalListener = listener; 45 | listener = (...args: EventMap[Type]["args"]) => { 46 | originalListener.apply(this, args); 47 | this.off(type, listener); 48 | }; 49 | } 50 | 51 | if (listeners.includes(listener)) return () => this.off(type, listener); 52 | listeners.splice(listeners.length, 0, listener); 53 | return () => this.off(type, listener); 54 | } 55 | 56 | /** 57 | * Remove event listeners 58 | * - If no event type is passed, every single listener will be removed 59 | * - If just type is passed with no listener, every listener for specific type will be removed 60 | * - If both type and listener is passed, just this specific listener will be removed 61 | */ 62 | off(): void; 63 | off(type: Type): void; 64 | off( 65 | type: Type, 66 | listener: EventListener, 67 | ): void; 68 | off( 69 | type?: Type, 70 | listener?: EventListener, 71 | ): void { 72 | if (!type) { 73 | this.listeners = {}; 74 | return; 75 | } 76 | 77 | if (!listener) { 78 | this.listeners[type] = []; 79 | return; 80 | } 81 | 82 | const listeners = this.listeners[type]; 83 | if (!listeners) return; 84 | listeners.splice(listeners.indexOf(listener), 1); 85 | } 86 | 87 | /** Emit specific type, after emitting all listeners associated with that event type will run with given arguments */ 88 | emit( 89 | type: Type, 90 | ...args: EventMap[Type]["args"] 91 | ): void { 92 | const listeners = this.listeners[type]; 93 | if (!listeners?.length) return; 94 | 95 | for (const listener of listeners!) { 96 | listener.apply(this, args); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/input_reader_decoders_keyboard.mjs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | /** Decode code sequence to {KeyPress} object. */ 3 | 4 | const lowerCaseAlphabet = "abcdefghijklmnopqrstuvwxyz"; 5 | 6 | const keyPress = { 7 | buffer: undefined, 8 | key: "-", 9 | meta: false, 10 | ctrl: false, 11 | shift: false, 12 | }; 13 | 14 | /** 15 | * Decode {buffer} and/or {code} to {KeyPressEvent} object 16 | * 17 | * **Don't hold onto event object reference that gets returned!** 18 | * 19 | * **It gets reused to save CPU usage and minimize GC.** 20 | */ 21 | export function decodeKey(buffer, code) { 22 | if (code[0] === "\x1b") code = code.slice(1); 23 | keyPress.buffer = buffer; 24 | keyPress.key = code; 25 | keyPress.ctrl = false; 26 | keyPress.meta = false; 27 | keyPress.shift = false; 28 | 29 | switch (code) { 30 | case "\r": 31 | case "\n": 32 | keyPress.key = "return"; 33 | break; 34 | case "\t": 35 | keyPress.key = "tab"; 36 | break; 37 | case "\b": 38 | case "\x7f": 39 | keyPress.key = "backspace"; 40 | break; 41 | case "\x1b": 42 | keyPress.key = "escape"; 43 | break; 44 | case " ": 45 | keyPress.key = "space"; 46 | break; 47 | default: 48 | { 49 | if (buffer[0] !== 27) { 50 | const offset96 = String.fromCharCode(buffer[0] + 96); 51 | if (lowerCaseAlphabet.indexOf(offset96) !== -1) { 52 | keyPress.key = offset96; 53 | keyPress.ctrl = true; 54 | break; 55 | } 56 | } 57 | 58 | if (code.length === 1) { 59 | keyPress.shift = code !== code.toLowerCase(); 60 | keyPress.meta = buffer[0] === 27; 61 | break; 62 | } else if (buffer.length === 1) { 63 | keyPress.key = "escape"; 64 | break; 65 | } 66 | 67 | const modifier = code.match(/\d+.+(\d+)/)?.[1] ?? ""; 68 | switch (modifier) { 69 | case "5": 70 | keyPress.ctrl = true; 71 | break; 72 | case "3": 73 | keyPress.meta = true; 74 | break; 75 | case "2": 76 | keyPress.shift = true; 77 | break; 78 | } 79 | 80 | code = code 81 | .replace(`1;${modifier}`, "") 82 | .replace(`;${modifier}`, "") 83 | .replace("1;", ""); 84 | switch (code) { 85 | case "OP": 86 | case "[P": 87 | keyPress.key = "f1"; 88 | break; 89 | case "OG": 90 | case "[Q": 91 | keyPress.key = "f2"; 92 | break; 93 | case "OR": 94 | case "[R": 95 | keyPress.key = "f3"; 96 | break; 97 | case "OS": 98 | case "[S": 99 | keyPress.key = "f4"; 100 | break; 101 | case "[15~": 102 | keyPress.key = "f5"; 103 | break; 104 | case "[17~": 105 | keyPress.key = "f6"; 106 | break; 107 | case "[18~": 108 | keyPress.key = "f7"; 109 | break; 110 | case "[19~": 111 | keyPress.key = "f8"; 112 | break; 113 | case "[20~": 114 | keyPress.key = "f9"; 115 | break; 116 | case "[21~": 117 | keyPress.key = "f10"; 118 | break; 119 | case "[23~": 120 | keyPress.key = "f11"; 121 | break; 122 | case "[24~": 123 | keyPress.key = "f12"; 124 | break; 125 | 126 | case "[A": 127 | keyPress.key = "up"; 128 | break; 129 | case "[B": 130 | keyPress.key = "down"; 131 | break; 132 | case "[C": 133 | keyPress.key = "right"; 134 | break; 135 | case "[D": 136 | keyPress.key = "left"; 137 | break; 138 | 139 | case "[2~": 140 | keyPress.key = "insert"; 141 | break; 142 | case "[3~": 143 | keyPress.key = "delete"; 144 | break; 145 | 146 | case "[5~": 147 | keyPress.key = "pageup"; 148 | break; 149 | case "[6~": 150 | keyPress.key = "pagedown"; 151 | break; 152 | 153 | case "[H": 154 | keyPress.key = "home"; 155 | break; 156 | case "[F": 157 | keyPress.key = "end"; 158 | break; 159 | 160 | case "[E": 161 | keyPress.key = "clear"; 162 | break; 163 | } 164 | } 165 | break; 166 | } 167 | 168 | return keyPress; 169 | } 170 | -------------------------------------------------------------------------------- /src/input_reader_decoders_keyboard.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | /** Decode code sequence to {KeyPress} object. */ 3 | import type { Alphabet, Key, KeyPressEvent } from "../types.ts"; 4 | 5 | const lowerCaseAlphabet = "abcdefghijklmnopqrstuvwxyz"; 6 | 7 | const keyPress: KeyPressEvent = { 8 | buffer: undefined as unknown as Uint8Array, 9 | key: "-", 10 | meta: false, 11 | ctrl: false, 12 | shift: false, 13 | }; 14 | 15 | /** 16 | * Decode {buffer} and/or {code} to {KeyPressEvent} object 17 | * 18 | * **Don't hold onto event object reference that gets returned!** 19 | * 20 | * **It gets reused to save CPU usage and minimize GC.** 21 | */ 22 | export function decodeKey(buffer: Uint8Array, code: string): KeyPressEvent { 23 | if (code[0] === "\x1b") code = code.slice(1); 24 | keyPress.buffer = buffer; 25 | keyPress.key = code as Key; 26 | keyPress.ctrl = false; 27 | keyPress.meta = false; 28 | keyPress.shift = false; 29 | 30 | switch (code) { 31 | case "\r": 32 | case "\n": 33 | keyPress.key = "return"; 34 | break; 35 | case "\t": 36 | keyPress.key = "tab"; 37 | break; 38 | case "\b": 39 | case "\x7f": 40 | keyPress.key = "backspace"; 41 | break; 42 | case "\x1b": 43 | keyPress.key = "escape"; 44 | break; 45 | case " ": 46 | keyPress.key = "space"; 47 | break; 48 | default: 49 | { 50 | if (buffer[0] !== 27) { 51 | const offset96 = String.fromCharCode(buffer[0] + 96); 52 | if (lowerCaseAlphabet.indexOf(offset96) !== -1) { 53 | keyPress.key = offset96 as Alphabet; 54 | keyPress.ctrl = true; 55 | break; 56 | } 57 | } 58 | 59 | if (code.length === 1) { 60 | keyPress.shift = code !== code.toLowerCase(); 61 | keyPress.meta = buffer[0] === 27; 62 | break; 63 | } else if (buffer.length === 1) { 64 | keyPress.key = "escape"; 65 | break; 66 | } 67 | 68 | const modifier = code.match(/\d+.+(\d+)/)?.[1] ?? ""; 69 | switch (modifier) { 70 | case "5": 71 | keyPress.ctrl = true; 72 | break; 73 | case "3": 74 | keyPress.meta = true; 75 | break; 76 | case "2": 77 | keyPress.shift = true; 78 | break; 79 | } 80 | 81 | code = code 82 | .replace(`1;${modifier}`, "") 83 | .replace(`;${modifier}`, "") 84 | .replace("1;", ""); 85 | switch (code) { 86 | case "OP": 87 | case "[P": 88 | keyPress.key = "f1"; 89 | break; 90 | case "OG": 91 | case "[Q": 92 | keyPress.key = "f2"; 93 | break; 94 | case "OR": 95 | case "[R": 96 | keyPress.key = "f3"; 97 | break; 98 | case "OS": 99 | case "[S": 100 | keyPress.key = "f4"; 101 | break; 102 | case "[15~": 103 | keyPress.key = "f5"; 104 | break; 105 | case "[17~": 106 | keyPress.key = "f6"; 107 | break; 108 | case "[18~": 109 | keyPress.key = "f7"; 110 | break; 111 | case "[19~": 112 | keyPress.key = "f8"; 113 | break; 114 | case "[20~": 115 | keyPress.key = "f9"; 116 | break; 117 | case "[21~": 118 | keyPress.key = "f10"; 119 | break; 120 | case "[23~": 121 | keyPress.key = "f11"; 122 | break; 123 | case "[24~": 124 | keyPress.key = "f12"; 125 | break; 126 | 127 | case "[A": 128 | keyPress.key = "up"; 129 | break; 130 | case "[B": 131 | keyPress.key = "down"; 132 | break; 133 | case "[C": 134 | keyPress.key = "right"; 135 | break; 136 | case "[D": 137 | keyPress.key = "left"; 138 | break; 139 | 140 | case "[2~": 141 | keyPress.key = "insert"; 142 | break; 143 | case "[3~": 144 | keyPress.key = "delete"; 145 | break; 146 | 147 | case "[5~": 148 | keyPress.key = "pageup"; 149 | break; 150 | case "[6~": 151 | keyPress.key = "pagedown"; 152 | break; 153 | 154 | case "[H": 155 | keyPress.key = "home"; 156 | break; 157 | case "[F": 158 | keyPress.key = "end"; 159 | break; 160 | 161 | case "[E": 162 | keyPress.key = "clear"; 163 | break; 164 | } 165 | } 166 | break; 167 | } 168 | 169 | return keyPress; 170 | } 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # teashop 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/teashop)](https://hex.pm/packages/teashop) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/teashop/) 5 | 6 | Teashop is a terminal application framework for Gleam based on [The Elm Architecture](https://guide.elm-lang.org/architecture/) heavily inspired by [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Mint Tea](https://github.com/leostera/minttea/). Teashop currently supports the Javascript target for Gleam. 7 | 8 | ## Tutorial 9 | 10 | ### Getting Started 11 | 12 | For this tutorial, let's build a tea shop. 13 | 14 | We'll start with creating a new Gleam project: 15 | 16 | ``` 17 | gleam new shop_tutorial 18 | ``` 19 | 20 | 21 | Then we add `teashop` to our project by adding it as a path dependency in our `gleam.toml`: 22 | 23 | ```toml 24 | [dependencies] 25 | teashop = { path = "../teashop" } 26 | ``` 27 | 28 | Now we can open up `src/shop_tutorial.gleam` and import the modules we'll need: 29 | 30 | ```gleam 31 | // Teashop modules 32 | import teashop 33 | import teashop/event 34 | import teashop/command 35 | import teashop/key 36 | 37 | // Gleam standard library modules 38 | import gleam/list 39 | import gleam/string 40 | ``` 41 | 42 | Teashop programs are composed of 3 parts: 43 | 44 | - `init`, that specifies an initial model and any commands to be run right after startup 45 | - `update`, that handles incoming events and updates the model accordingly 46 | - `view`, that turns your model into a string to be rendered 47 | 48 | ### The Model 49 | 50 | First we create a type and constructor for our model. 51 | This model will be responsible for holding all of the state we use throughout the app. 52 | 53 | ```gleam 54 | pub type Status { 55 | Selected 56 | Unselected 57 | } 58 | 59 | pub type Model { 60 | Model(choices: List(#(String, Status)), cursor: Int) 61 | } 62 | ``` 63 | 64 | ### Initialization 65 | 66 | We need to define our `init` function. It returns the initial model, 67 | and a command to perform any initial input or output. 68 | 69 | Here, I've specified the initial model as a constant: 70 | 71 | ```gleam 72 | const initial_model = Model( 73 | cursor: 0, 74 | choices: [ 75 | #("Kitten cuddles 🐈", Unselected), 76 | #("Strawberry shortcake 🍰", Unselected), 77 | #("Blueberry muffins 🫐", Unselected), 78 | ], 79 | ) 80 | ``` 81 | 82 | Which we can use in the actual `init` function here. 83 | Here, we use a command to set the window title to something appropriate for our app: 84 | 85 | ``` 86 | pub fn init(_) { 87 | #(initial_model, command.set_window_title("teashop")) 88 | } 89 | ``` 90 | 91 | ### Update 92 | 93 | ```gleam 94 | pub fn update(model: Model, event) { 95 | case event { 96 | event.Key(key.Char("q")) | event.Key(key.Esc) -> #(model, command.quit()) 97 | 98 | event.Key(key.Char("k")) | event.Key(key.Up) -> { 99 | let choices_len = list.length(model.choices) 100 | let cursor = case model.cursor == 0 { 101 | True -> choices_len - 1 102 | False -> model.cursor - 1 103 | } 104 | #(Model(..model, cursor: cursor), command.none()) 105 | } 106 | 107 | event.Key(key.Char("j")) | event.Key(key.Down) -> { 108 | let choices_len = list.length(model.choices) 109 | let cursor = case model.cursor == { choices_len - 1 } { 110 | True -> 0 111 | False -> model.cursor + 1 112 | } 113 | #(Model(..model, cursor: cursor), command.none()) 114 | } 115 | 116 | event.Key(key.Enter) | event.Key(key.Space) -> { 117 | let toggle = fn(status) { 118 | case status { 119 | Selected -> Unselected 120 | Unselected -> Selected 121 | } 122 | } 123 | let choices = 124 | list.index_map(model.choices, fn(element, index) { 125 | let #(name, status) = element 126 | let status = case index == model.cursor { 127 | True -> toggle(status) 128 | False -> status 129 | } 130 | #(name, status) 131 | }) 132 | #(Model(..model, choices: choices), command.none()) 133 | } 134 | _otherwise -> #(model, command.none()) 135 | } 136 | } 137 | ``` 138 | 139 | ### View 140 | 141 | ```gleam 142 | pub fn view(model: Model) { 143 | let options = 144 | model.choices 145 | |> list.index_map(fn(element, index) { 146 | let #(name, status) = element 147 | let cursor = case model.cursor == index { 148 | True -> ">" 149 | False -> " " 150 | } 151 | let checked = case status { 152 | Selected -> "x" 153 | _ -> " " 154 | } 155 | cursor <> " [" <> checked <> "] " <> name 156 | }) 157 | |> string.join("\n") 158 | 159 | let header = "What should we get at the tea shop?" 160 | let footer = "Press q to quit." 161 | 162 | [header, options, footer] 163 | |> string.join("\n\n") 164 | } 165 | ``` 166 | 167 | ### All Together Now 168 | 169 | ```gleam 170 | pub fn main() { 171 | let app = teashop.app(init, update, view) 172 | teashop.start(app, Nil) 173 | } 174 | ``` 175 | 176 | ### Running our teashop: 177 | 178 | ``` 179 | gleam run -m shop_tutorial --target js 180 | ``` 181 | 182 | ## Acknowledgments 183 | 184 | Teashop is based on [The Elm Architecture](https://guide.elm-lang.org/architecture/) by Evan Czaplicki et alia. It is heavily inspired by [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Mint Tea](https://github.com/leostera/minttea/). Teashop builds on work from [deno_tui](https://github.com/Im-Beast/deno_tui) and borrows heavily from [Lustre](https://lustre.build/). 185 | -------------------------------------------------------------------------------- /src/input_reader_decoders_mouse.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Im-Beast. MIT license. 2 | import type { 3 | MouseEvent, 4 | MousePressEvent, 5 | MouseScrollEvent, 6 | } from "../types.ts"; 7 | 8 | let mouseEvent: MouseEvent = { 9 | key: "mouse", 10 | x: 0, 11 | y: 0, 12 | movementX: 0, 13 | movementY: 0, 14 | buffer: undefined as unknown as Uint8Array, 15 | shift: false, 16 | ctrl: false, 17 | meta: false, 18 | }; 19 | let lastMouseEvent: MouseEvent = { ...mouseEvent }; 20 | 21 | /** 22 | * Decode SGR mouse mode code sequence to {MouseEvent} object. 23 | * If it can't convert specified {code} to {MouseEvent} it returns undefined. 24 | * 25 | * **Don't hold onto event object reference that gets returned!** 26 | * 27 | * **It gets reused to save CPU usage and minimize GC.** 28 | */ 29 | export function decodeMouseSGR( 30 | buffer: Uint8Array, 31 | code: string, 32 | ): MousePressEvent | MouseScrollEvent | undefined { 33 | const action = code.at(-1); 34 | if (!code.startsWith("\x1b[<") || (action !== "m" && action !== "M")) { 35 | return undefined; 36 | } 37 | 38 | const release = action === "m"; 39 | 40 | const xSeparator = code.indexOf(";"); 41 | let modifiers = +code.slice(3, xSeparator); 42 | const ySeparator = code.indexOf(";", xSeparator + 1); 43 | let x = +code.slice(xSeparator + 1, ySeparator); 44 | let y = +code.slice(ySeparator + 1, code.length - 1); 45 | 46 | x -= 1; 47 | y -= 1; 48 | 49 | const movementX = lastMouseEvent ? x - lastMouseEvent.x : 0; 50 | const movementY = lastMouseEvent ? y - lastMouseEvent.y : 0; 51 | 52 | let scroll: MouseScrollEvent["scroll"] = 0; 53 | if (modifiers >= 64) { 54 | scroll = modifiers % 2 === 0 ? -1 : 1; 55 | modifiers -= scroll < 0 ? 64 : 65; 56 | } 57 | 58 | let drag = false; 59 | if (modifiers >= 32) { 60 | drag = true; 61 | modifiers -= 32; 62 | } 63 | 64 | let ctrl = false; 65 | if (modifiers >= 16) { 66 | ctrl = true; 67 | modifiers -= 16; 68 | } 69 | 70 | let meta = false; 71 | if (modifiers >= 8) { 72 | meta = true; 73 | modifiers -= 8; 74 | } 75 | 76 | let shift = false; 77 | if (modifiers >= 4) { 78 | shift = true; 79 | modifiers -= 4; 80 | } 81 | 82 | let button: MousePressEvent["button"]; 83 | if (!scroll) { 84 | button = modifiers as MousePressEvent["button"]; 85 | } 86 | 87 | lastMouseEvent = mouseEvent; 88 | const previous = lastMouseEvent; 89 | mouseEvent = previous; 90 | 91 | // Clear data from previous events 92 | const allMouseEvents = mouseEvent as Partial< 93 | MousePressEvent & MouseScrollEvent 94 | >; 95 | delete allMouseEvents.scroll; 96 | delete allMouseEvents.drag; 97 | delete allMouseEvents.button; 98 | delete allMouseEvents.release; 99 | 100 | mouseEvent.buffer = buffer; 101 | mouseEvent.x = x; 102 | mouseEvent.y = y; 103 | mouseEvent.ctrl = ctrl; 104 | mouseEvent.meta = meta; 105 | mouseEvent.shift = shift; 106 | mouseEvent.movementX = movementX; 107 | mouseEvent.movementY = movementY; 108 | 109 | if (scroll) { 110 | const mouseScrollEvent = mouseEvent as MouseScrollEvent; 111 | mouseScrollEvent.scroll = scroll; 112 | return mouseScrollEvent; 113 | } else { 114 | const mousePressEvent = mouseEvent as MousePressEvent; 115 | mousePressEvent.drag = drag; 116 | mousePressEvent.button = button!; 117 | mousePressEvent.release = release; 118 | return mousePressEvent; 119 | } 120 | } 121 | 122 | /** 123 | * Decode VT and UTF8 mouse mode code sequence to {MouseEvent} object. 124 | * If it can't convert specified {code} to {MouseEvent} it returns undefined. 125 | * 126 | * **Don't hold onto event object reference that gets returned!** 127 | * 128 | * **It gets reused to save CPU usage and minimize GC.** 129 | */ 130 | export function decodeMouseVT_UTF8( 131 | buffer: Uint8Array, 132 | code: string, 133 | ): MousePressEvent | MouseScrollEvent | undefined { 134 | if (!code.startsWith("\x1b[M")) return undefined; 135 | 136 | const modifiers = code.charCodeAt(3); 137 | let x = code.charCodeAt(4); 138 | let y = code.charCodeAt(5); 139 | 140 | x -= 0o41; 141 | y -= 0o41; 142 | 143 | const movementX = lastMouseEvent ? x - lastMouseEvent.x : 0; 144 | const movementY = lastMouseEvent ? y - lastMouseEvent.y : 0; 145 | 146 | const buttonInfo = modifiers & 3; 147 | let release = false; 148 | 149 | let button: MousePressEvent["button"]; 150 | if (buttonInfo === 3) { 151 | release = true; 152 | } else { 153 | button = buttonInfo as MousePressEvent["button"]; 154 | } 155 | 156 | const shift = !!(modifiers & 4); 157 | const meta = !!(modifiers & 8); 158 | const ctrl = !!(modifiers & 16); 159 | const scroll = 160 | button && !!(modifiers & 32) && !!(modifiers & 64) 161 | ? modifiers & 3 162 | ? 1 163 | : -1 164 | : 0; 165 | if (scroll) button = undefined; 166 | const drag = !scroll && !!(modifiers & 64); 167 | 168 | lastMouseEvent = mouseEvent; 169 | const previous = lastMouseEvent; 170 | mouseEvent = previous; 171 | 172 | // Clear data from previous events 173 | const allMouseEvents = mouseEvent as Partial< 174 | MousePressEvent & MouseScrollEvent 175 | >; 176 | delete allMouseEvents.scroll; 177 | delete allMouseEvents.drag; 178 | delete allMouseEvents.button; 179 | delete allMouseEvents.release; 180 | 181 | mouseEvent.buffer = buffer; 182 | mouseEvent.x = x; 183 | mouseEvent.y = y; 184 | mouseEvent.ctrl = ctrl; 185 | mouseEvent.meta = meta; 186 | mouseEvent.shift = shift; 187 | mouseEvent.movementX = movementX; 188 | mouseEvent.movementY = movementY; 189 | 190 | if (scroll) { 191 | const mouseScrollEvent = mouseEvent as MouseScrollEvent; 192 | mouseScrollEvent.scroll = scroll; 193 | return mouseScrollEvent; 194 | } else { 195 | const mousePressEvent = mouseEvent as MousePressEvent; 196 | mousePressEvent.drag = drag; 197 | mousePressEvent.button = button!; 198 | mousePressEvent.release = release; 199 | return mousePressEvent; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/keypress.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | // var EventEmitter = require('events').EventEmitter; 6 | 7 | import { EventEmitter } from "node:events"; 8 | import { StringDecoder } from "node:string_decoder"; 9 | 10 | /** 11 | * Module exports. 12 | */ 13 | 14 | // exports = module.exports = keypress; 15 | 16 | /** 17 | * This module offers the internal "keypress" functionality from node-core's 18 | * `readline` module, for your own programs and modules to use. 19 | * 20 | * The `keypress` function accepts a readable Stream instance and makes it 21 | * emit "keypress" events. 22 | * 23 | * Usage: 24 | * 25 | * ``` js 26 | * require('keypress')(process.stdin); 27 | * 28 | * process.stdin.on('keypress', function (ch, key) { 29 | * console.log(ch, key); 30 | * if (key.ctrl && key.name == 'c') { 31 | * process.stdin.pause(); 32 | * } 33 | * }); 34 | * process.stdin.resume(); 35 | * ``` 36 | * 37 | * @param {Stream} stream 38 | * @api public 39 | */ 40 | 41 | export default { keypress }; 42 | 43 | export function keypress(stream) { 44 | if (isEmittingKeypress(stream)) return; 45 | 46 | // var StringDecoder = require('string_decoder').StringDecoder; // lazy load 47 | stream._keypressDecoder = new StringDecoder("utf8"); 48 | 49 | function onData(b) { 50 | if (listenerCount(stream, "keypress") > 0) { 51 | var r = stream._keypressDecoder.write(b); 52 | if (r) emitKey(stream, r); 53 | } else { 54 | // Nobody's watching anyway 55 | stream.removeListener("data", onData); 56 | stream.on("newListener", onNewListener); 57 | } 58 | } 59 | 60 | function onNewListener(event) { 61 | if (event == "keypress") { 62 | stream.on("data", onData); 63 | stream.removeListener("newListener", onNewListener); 64 | } 65 | } 66 | 67 | if (listenerCount(stream, "keypress") > 0) { 68 | stream.on("data", onData); 69 | } else { 70 | stream.on("newListener", onNewListener); 71 | } 72 | } 73 | 74 | /** 75 | * Returns `true` if the stream is already emitting "keypress" events. 76 | * `false` otherwise. 77 | * 78 | * @param {Stream} stream readable stream 79 | * @return {Boolean} `true` if the stream is emitting "keypress" events 80 | * @api private 81 | */ 82 | 83 | function isEmittingKeypress(stream) { 84 | var rtn = !!stream._keypressDecoder; 85 | if (!rtn) { 86 | // XXX: for older versions of node (v0.6.x, v0.8.x) we want to remove the 87 | // existing "data" and "newListener" keypress events since they won't include 88 | // this `keypress` module extensions (like "mousepress" events). 89 | stream 90 | .listeners("data") 91 | .slice(0) 92 | .forEach(function (l) { 93 | if (l.name == "onData" && /emitKey/.test(l.toString())) { 94 | stream.removeListener("data", l); 95 | } 96 | }); 97 | stream 98 | .listeners("newListener") 99 | .slice(0) 100 | .forEach(function (l) { 101 | if (l.name == "onNewListener" && /keypress/.test(l.toString())) { 102 | stream.removeListener("newListener", l); 103 | } 104 | }); 105 | } 106 | return rtn; 107 | } 108 | 109 | /** 110 | * Enables "mousepress" events on the *input* stream. Note that `stream` must be 111 | * an *output* stream (i.e. a Writable Stream instance), usually `process.stdout`. 112 | * 113 | * @param {Stream} stream writable stream instance 114 | * @api public 115 | */ 116 | let exports = {}; 117 | exports.enableMouse = function (stream) { 118 | stream.write("\x1b[?1000h"); 119 | }; 120 | 121 | /** 122 | * Disables "mousepress" events from being sent to the *input* stream. 123 | * Note that `stream` must be an *output* stream (i.e. a Writable Stream instance), 124 | * usually `process.stdout`. 125 | * 126 | * @param {Stream} stream writable stream instance 127 | * @api public 128 | */ 129 | 130 | exports.disableMouse = function (stream) { 131 | stream.write("\x1b[?1000l"); 132 | }; 133 | 134 | /** 135 | * `EventEmitter.listenerCount()` polyfill, for backwards compat. 136 | * 137 | * @param {Emitter} emitter event emitter instance 138 | * @param {String} event event name 139 | * @return {Number} number of listeners for `event` 140 | * @api public 141 | */ 142 | 143 | var listenerCount = EventEmitter.listenerCount; 144 | if (!listenerCount) { 145 | listenerCount = function (emitter, event) { 146 | return emitter.listeners(event).length; 147 | }; 148 | } 149 | 150 | /////////////////////////////////////////////////////////////////////// 151 | // Below this function is code from node-core's `readline.js` module // 152 | /////////////////////////////////////////////////////////////////////// 153 | 154 | /* 155 | Some patterns seen in terminal key escape codes, derived from combos seen 156 | at http://www.midnight-commander.org/browser/lib/tty/key.c 157 | 158 | ESC letter 159 | ESC [ letter 160 | ESC [ modifier letter 161 | ESC [ 1 ; modifier letter 162 | ESC [ num char 163 | ESC [ num ; modifier char 164 | ESC O letter 165 | ESC O modifier letter 166 | ESC O 1 ; modifier letter 167 | ESC N letter 168 | ESC [ [ num ; modifier char 169 | ESC [ [ 1 ; modifier letter 170 | ESC ESC [ num char 171 | ESC ESC O letter 172 | 173 | - char is usually ~ but $ and ^ also happen with rxvt 174 | - modifier is 1 + 175 | (shift * 1) + 176 | (left_alt * 2) + 177 | (ctrl * 4) + 178 | (right_alt * 8) 179 | - two leading ESCs apparently mean the same as one leading ESC 180 | */ 181 | 182 | // Regexes used for ansi escape code splitting 183 | var metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/; 184 | var functionKeyCodeRe = 185 | /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; 186 | 187 | function emitKey(stream, s) { 188 | var ch, 189 | key = { 190 | name: undefined, 191 | ctrl: false, 192 | meta: false, 193 | shift: false, 194 | }, 195 | parts; 196 | 197 | if (Buffer.isBuffer(s)) { 198 | if (s[0] > 127 && s[1] === undefined) { 199 | s[0] -= 128; 200 | s = "\x1b" + s.toString(stream.encoding || "utf-8"); 201 | } else { 202 | s = s.toString(stream.encoding || "utf-8"); 203 | } 204 | } 205 | 206 | key.sequence = s; 207 | 208 | if (s === "\r") { 209 | // carriage return 210 | key.name = "return"; 211 | } else if (s === "\n") { 212 | // enter, should have been called linefeed 213 | key.name = "enter"; 214 | } else if (s === "\t") { 215 | // tab 216 | key.name = "tab"; 217 | } else if (s === "\b" || s === "\x7f" || s === "\x1b\x7f" || s === "\x1b\b") { 218 | // backspace or ctrl+h 219 | key.name = "backspace"; 220 | key.meta = s.charAt(0) === "\x1b"; 221 | } else if (s === "\x1b" || s === "\x1b\x1b") { 222 | // escape key 223 | key.name = "escape"; 224 | key.meta = s.length === 2; 225 | } else if (s === " " || s === "\x1b ") { 226 | key.name = "space"; 227 | key.meta = s.length === 2; 228 | } else if (s <= "\x1a") { 229 | // ctrl+letter 230 | key.name = String.fromCharCode(s.charCodeAt(0) + "a".charCodeAt(0) - 1); 231 | key.ctrl = true; 232 | } else if (s.length === 1 && s >= "A" && s <= "Z") { 233 | // shift+letter 234 | key.name = s; 235 | key.shift = true; 236 | } else if (s.length === 1) { 237 | // lowercase letter 238 | key.name = s; 239 | } else if ((parts = metaKeyCodeRe.exec(s))) { 240 | // meta+character key 241 | key.name = parts[1].toLowerCase(); 242 | key.meta = true; 243 | key.shift = /^[A-Z]$/.test(parts[1]); 244 | } else if ((parts = functionKeyCodeRe.exec(s))) { 245 | // ansi escape sequence 246 | 247 | // reassemble the key code leaving out leading \x1b's, 248 | // the modifier key bitflag and any meaningless "1;" sequence 249 | var code = 250 | (parts[1] || "") + 251 | (parts[2] || "") + 252 | (parts[4] || "") + 253 | (parts[6] || ""), 254 | modifier = (parts[3] || parts[5] || 1) - 1; 255 | 256 | // Parse the key modifier 257 | key.ctrl = !!(modifier & 4); 258 | key.meta = !!(modifier & 10); 259 | key.shift = !!(modifier & 1); 260 | key.code = code; 261 | 262 | // Parse the key itself 263 | switch (code) { 264 | /* xterm/gnome ESC O letter */ 265 | case "OP": 266 | key.name = "f1"; 267 | break; 268 | case "OQ": 269 | key.name = "f2"; 270 | break; 271 | case "OR": 272 | key.name = "f3"; 273 | break; 274 | case "OS": 275 | key.name = "f4"; 276 | break; 277 | 278 | /* xterm/rxvt ESC [ number ~ */ 279 | case "[11~": 280 | key.name = "f1"; 281 | break; 282 | case "[12~": 283 | key.name = "f2"; 284 | break; 285 | case "[13~": 286 | key.name = "f3"; 287 | break; 288 | case "[14~": 289 | key.name = "f4"; 290 | break; 291 | 292 | /* from Cygwin and used in libuv */ 293 | case "[[A": 294 | key.name = "f1"; 295 | break; 296 | case "[[B": 297 | key.name = "f2"; 298 | break; 299 | case "[[C": 300 | key.name = "f3"; 301 | break; 302 | case "[[D": 303 | key.name = "f4"; 304 | break; 305 | case "[[E": 306 | key.name = "f5"; 307 | break; 308 | 309 | /* common */ 310 | case "[15~": 311 | key.name = "f5"; 312 | break; 313 | case "[17~": 314 | key.name = "f6"; 315 | break; 316 | case "[18~": 317 | key.name = "f7"; 318 | break; 319 | case "[19~": 320 | key.name = "f8"; 321 | break; 322 | case "[20~": 323 | key.name = "f9"; 324 | break; 325 | case "[21~": 326 | key.name = "f10"; 327 | break; 328 | case "[23~": 329 | key.name = "f11"; 330 | break; 331 | case "[24~": 332 | key.name = "f12"; 333 | break; 334 | 335 | /* xterm ESC [ letter */ 336 | case "[A": 337 | key.name = "up"; 338 | break; 339 | case "[B": 340 | key.name = "down"; 341 | break; 342 | case "[C": 343 | key.name = "right"; 344 | break; 345 | case "[D": 346 | key.name = "left"; 347 | break; 348 | case "[E": 349 | key.name = "clear"; 350 | break; 351 | case "[F": 352 | key.name = "end"; 353 | break; 354 | case "[H": 355 | key.name = "home"; 356 | break; 357 | 358 | /* xterm/gnome ESC O letter */ 359 | case "OA": 360 | key.name = "up"; 361 | break; 362 | case "OB": 363 | key.name = "down"; 364 | break; 365 | case "OC": 366 | key.name = "right"; 367 | break; 368 | case "OD": 369 | key.name = "left"; 370 | break; 371 | case "OE": 372 | key.name = "clear"; 373 | break; 374 | case "OF": 375 | key.name = "end"; 376 | break; 377 | case "OH": 378 | key.name = "home"; 379 | break; 380 | 381 | /* xterm/rxvt ESC [ number ~ */ 382 | case "[1~": 383 | key.name = "home"; 384 | break; 385 | case "[2~": 386 | key.name = "insert"; 387 | break; 388 | case "[3~": 389 | key.name = "delete"; 390 | break; 391 | case "[4~": 392 | key.name = "end"; 393 | break; 394 | case "[5~": 395 | key.name = "pageup"; 396 | break; 397 | case "[6~": 398 | key.name = "pagedown"; 399 | break; 400 | 401 | /* putty */ 402 | case "[[5~": 403 | key.name = "pageup"; 404 | break; 405 | case "[[6~": 406 | key.name = "pagedown"; 407 | break; 408 | 409 | /* rxvt */ 410 | case "[7~": 411 | key.name = "home"; 412 | break; 413 | case "[8~": 414 | key.name = "end"; 415 | break; 416 | 417 | /* rxvt keys with modifiers */ 418 | case "[a": 419 | key.name = "up"; 420 | key.shift = true; 421 | break; 422 | case "[b": 423 | key.name = "down"; 424 | key.shift = true; 425 | break; 426 | case "[c": 427 | key.name = "right"; 428 | key.shift = true; 429 | break; 430 | case "[d": 431 | key.name = "left"; 432 | key.shift = true; 433 | break; 434 | case "[e": 435 | key.name = "clear"; 436 | key.shift = true; 437 | break; 438 | 439 | case "[2$": 440 | key.name = "insert"; 441 | key.shift = true; 442 | break; 443 | case "[3$": 444 | key.name = "delete"; 445 | key.shift = true; 446 | break; 447 | case "[5$": 448 | key.name = "pageup"; 449 | key.shift = true; 450 | break; 451 | case "[6$": 452 | key.name = "pagedown"; 453 | key.shift = true; 454 | break; 455 | case "[7$": 456 | key.name = "home"; 457 | key.shift = true; 458 | break; 459 | case "[8$": 460 | key.name = "end"; 461 | key.shift = true; 462 | break; 463 | 464 | case "Oa": 465 | key.name = "up"; 466 | key.ctrl = true; 467 | break; 468 | case "Ob": 469 | key.name = "down"; 470 | key.ctrl = true; 471 | break; 472 | case "Oc": 473 | key.name = "right"; 474 | key.ctrl = true; 475 | break; 476 | case "Od": 477 | key.name = "left"; 478 | key.ctrl = true; 479 | break; 480 | case "Oe": 481 | key.name = "clear"; 482 | key.ctrl = true; 483 | break; 484 | 485 | case "[2^": 486 | key.name = "insert"; 487 | key.ctrl = true; 488 | break; 489 | case "[3^": 490 | key.name = "delete"; 491 | key.ctrl = true; 492 | break; 493 | case "[5^": 494 | key.name = "pageup"; 495 | key.ctrl = true; 496 | break; 497 | case "[6^": 498 | key.name = "pagedown"; 499 | key.ctrl = true; 500 | break; 501 | case "[7^": 502 | key.name = "home"; 503 | key.ctrl = true; 504 | break; 505 | case "[8^": 506 | key.name = "end"; 507 | key.ctrl = true; 508 | break; 509 | 510 | /* misc. */ 511 | case "[Z": 512 | key.name = "tab"; 513 | key.shift = true; 514 | break; 515 | default: 516 | key.name = "undefined"; 517 | break; 518 | } 519 | } else if (s.length > 1 && s[0] !== "\x1b") { 520 | // Got a longer-than-one string of characters. 521 | // Probably a paste, since it wasn't a control sequence. 522 | Array.prototype.forEach.call(s, function (c) { 523 | emitKey(stream, c); 524 | }); 525 | return; 526 | } 527 | 528 | // XXX: this "mouse" parsing code is NOT part of the node-core standard 529 | // `readline.js` module, and is a `keypress` module non-standard extension. 530 | if (key.code == "[M") { 531 | key.name = "mouse"; 532 | var s = key.sequence; 533 | var b = s.charCodeAt(3); 534 | // key.x = s.charCodeAt(4) - 040; 535 | // key.y = s.charCodeAt(5) - 040; 536 | key.x = s.charCodeAt(4) - 32; // octal 040 537 | key.y = s.charCodeAt(5) - 32; // octal 040 538 | 539 | key.scroll = 0; 540 | 541 | key.ctrl = !!((1 << 4) & b); 542 | key.meta = !!((1 << 3) & b); 543 | key.shift = !!((1 << 2) & b); 544 | 545 | key.release = (3 & b) === 3; 546 | 547 | if ((1 << 6) & b) { 548 | //scroll 549 | key.scroll = 1 & b ? 1 : -1; 550 | } 551 | 552 | if (!key.release && !key.scroll) { 553 | key.button = b & 3; 554 | } 555 | } 556 | 557 | // Don't emit a key if no name was found 558 | if (key.name === undefined) { 559 | key = undefined; 560 | } 561 | 562 | if (s.length === 1) { 563 | ch = s; 564 | } 565 | 566 | if (key && key.name == "mouse") { 567 | stream.emit("mousepress", key); 568 | } else if (key || ch) { 569 | stream.emit("keypress", ch, key); 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /src/teashop.ffi.mjs: -------------------------------------------------------------------------------- 1 | import * as child_process from "node:child_process"; 2 | import { 3 | 4 | Shutdown, 5 | Send, 6 | WindowTitle, 7 | ReleaseTerminal, 8 | RestoreTerminal, 9 | } from "./teashop/internal/action.mjs"; 10 | 11 | import { 12 | // Frame, 13 | Key as KeyEvent, 14 | Custom as CustomEvent, 15 | Resize, 16 | } from "./teashop/event.mjs"; 17 | 18 | import { 19 | AltScreenEnabled, 20 | AltScreenDisabled, 21 | } from "./teashop/internal/renderer_options.mjs"; 22 | 23 | import { 24 | Quit, 25 | Noop, 26 | HideCursor, 27 | ShowCursor, 28 | EnterAltScreen, 29 | ExitAltScreen, 30 | ClearScreen, 31 | SetWindowTitle, 32 | Seq, 33 | ExecuteProcess, 34 | SetTimer, 35 | Custom as CustomCommand, 36 | } from "./teashop/command.mjs"; 37 | 38 | import { 39 | Backspace, 40 | Left, 41 | Right, 42 | Up, 43 | Down, 44 | Home, 45 | End, 46 | PageUp, 47 | PageDown, 48 | Tab, 49 | Space, 50 | Delete, 51 | Insert, 52 | Enter, 53 | FKey, 54 | Char, 55 | Alt, 56 | Ctrl, 57 | Shift, 58 | Esc, 59 | Unknown, 60 | } from "./teashop/key.mjs"; 61 | 62 | import process from "node:process"; 63 | 64 | const get_key_name = (key) => { 65 | return key.key; 66 | if (globalThis.Deno) { 67 | return key.key; 68 | } else { 69 | return key.name; 70 | } 71 | }; 72 | 73 | const parse_key_type = (key) => { 74 | let name = get_key_name(key); 75 | switch (name) { 76 | case "backspace": 77 | return new Backspace(); 78 | break; 79 | case "left": 80 | return new Left(); 81 | break; 82 | case "right": 83 | return new Right(); 84 | break; 85 | case "up": 86 | return new Up(); 87 | break; 88 | case "down": 89 | return new Down(); 90 | break; 91 | case "home": 92 | return new Home(); 93 | break; 94 | case "end": 95 | return new End(); 96 | break; 97 | case "pageup": 98 | return new PageUp(); 99 | break; 100 | case "pagedown": 101 | return new PageDown(); 102 | break; 103 | case "f1": 104 | return new FKey(1); 105 | break; 106 | case "f2": 107 | return new FKey(2); 108 | break; 109 | case "f3": 110 | return new FKey(3); 111 | break; 112 | case "f4": 113 | return new FKey(4); 114 | break; 115 | case "f5": 116 | return new FKey(5); 117 | break; 118 | case "f6": 119 | return new FKey(6); 120 | break; 121 | case "f7": 122 | return new FKey(7); 123 | break; 124 | case "f8": 125 | return new FKey(8); 126 | break; 127 | case "f9": 128 | return new FKey(9); 129 | break; 130 | case "f10": 131 | return new FKey(10); 132 | break; 133 | case "f11": 134 | return new FKey(11); 135 | break; 136 | case "f12": 137 | return new FKey(12); 138 | break; 139 | case "tab": 140 | return new Tab(); 141 | break; 142 | case "delete": 143 | return new Delete(); 144 | break; 145 | case "insert": 146 | return new Insert(); 147 | break; 148 | case "escape": 149 | return new Esc(); 150 | break; 151 | case "return": 152 | return new Enter(); 153 | break; 154 | case "enter": 155 | return new Enter(); 156 | break; 157 | case "space": 158 | return new Space(); 159 | break; 160 | default: 161 | return new Char(name); 162 | break; 163 | } 164 | }; 165 | 166 | const handle_modifier = (key, parsed_key) => { 167 | if (key.meta) { 168 | return new Alt(parsed_key); 169 | } else if (key.ctrl) { 170 | return new Ctrl(parsed_key); 171 | } else if (key.shift) { 172 | if (parsed_key instanceof Char) { 173 | return parsed_key; 174 | } else { 175 | return new Shift(parsed_key); 176 | } 177 | } else { 178 | return parsed_key; 179 | } 180 | }; 181 | 182 | // EventEmitter implementation modified from `deno_tui`: 183 | // Copyright 2023 Im-Beast. MIT license. 184 | 185 | /** Custom implementation of event emitter */ 186 | class EventEmitter { 187 | listeners = {}; 188 | 189 | on(type, listener, once) { 190 | let listeners = this.listeners[type]; 191 | if (!listeners) { 192 | listeners = []; 193 | this.listeners[type] = listeners; 194 | } 195 | 196 | if (once) { 197 | const originalListener = listener; 198 | listener = (...args) => { 199 | originalListener.apply(this, args); 200 | this.off(type, listener); 201 | }; 202 | } 203 | 204 | if (listeners.includes(listener)) return () => this.off(type, listener); 205 | listeners.splice(listeners.length, 0, listener); 206 | return () => this.off(type, listener); 207 | } 208 | 209 | off(type, listener) { 210 | if (!type) { 211 | this.listeners = {}; 212 | return; 213 | } 214 | 215 | if (!listener) { 216 | this.listeners[type] = []; 217 | return; 218 | } 219 | 220 | const listeners = this.listeners[type]; 221 | if (!listeners) return; 222 | listeners.splice(listeners.indexOf(listener), 1); 223 | } 224 | 225 | emit(type, ...args) { 226 | const listeners = this.listeners[type]; 227 | if (!listeners?.length) return; 228 | 229 | for (const listener of listeners) { 230 | listener.apply(this, args); 231 | } 232 | } 233 | } 234 | // const handleKeyboardInput = (tui) => { 235 | // if (globalThis.Deno) { 236 | // import("./input.ts").then((mod) => { 237 | // tui.stdin = Deno.stdin; 238 | 239 | // mod.handleInput(tui); 240 | // }); 241 | // } else { 242 | // import("./keypress.mjs").then((mod) => { 243 | // mod.keypress(process.stdin); 244 | 245 | // // listen for the "keypress" event 246 | // process.stdin.on("keypress", function (ch, key) { 247 | // // console.log('got "keypress"', key); 248 | 249 | // tui.emit("keyPress", key); 250 | // if (key && key.ctrl && key.name == "c") { 251 | // tui.emit("destroy"); 252 | // } 253 | // }); 254 | 255 | // process.stdin.setRawMode(true); 256 | // process.stdin.resume(); 257 | // }); 258 | // } 259 | // }; 260 | 261 | import { decodeKey } from "./input_reader_decoders_keyboard.mjs"; 262 | import { StringDecoder } from "node:string_decoder"; 263 | 264 | let decoder = new StringDecoder("utf8"); 265 | 266 | export function* decodeBuffer(buffer) { 267 | const code = decoder.write(buffer); 268 | const lastIndex = code.lastIndexOf("\x1b"); 269 | 270 | if (code.indexOf("\x1b") !== lastIndex) { 271 | yield* decodeBuffer(buffer.subarray(0, lastIndex)); 272 | yield* decodeBuffer(buffer.subarray(lastIndex)); 273 | } else { 274 | // yield decodeMouseVT_UTF8(buffer, code) ?? 275 | // decodeMouseSGR(buffer, code) ?? 276 | yield decodeKey(buffer, code); 277 | } 278 | } 279 | 280 | const denoReader = (() => { 281 | if (globalThis.Deno) { 282 | return Deno.stdin.readable.getReader(); 283 | } 284 | })(); 285 | 286 | const handleBuffer = (tui, buffer) => { 287 | for (const event of decodeBuffer(buffer)) { 288 | // if (event.key === "mouse") { 289 | // emitter.emit("mouseEvent", event); 290 | 291 | // if ("button" in event) { 292 | // emitter.emit("mousePress", event); 293 | // } else if ("scroll" in event) { 294 | // emitter.emit("mouseScroll", event); 295 | // } 296 | // } else { 297 | tui.emit("keyPress", event); 298 | // console.log(event); 299 | if (event && event.ctrl && event.key == "c") { 300 | tui.emit("destroy"); 301 | } 302 | // } 303 | } 304 | }; 305 | 306 | function handleKeyInputDeno(tui) { 307 | const listener = new DenoListener(); 308 | listener.tui = tui; 309 | listener.run(); 310 | return listener; 311 | } 312 | 313 | class DenoListener extends EventEmitter { 314 | // make into enum? 315 | state = "reading"; 316 | tui; 317 | 318 | onCancel() { 319 | // make into enum? 320 | this.state = "cancel"; 321 | this.off("cancel", this.onCancel); 322 | } 323 | 324 | run() { 325 | this.on("cancel", this.onCancel); 326 | this.start(); 327 | } 328 | async start() { 329 | let buffer = new Uint8Array(0); 330 | while (true) { 331 | if (buffer.length === 0) { 332 | const readResult = await denoReader.read(); 333 | buffer = readResult.value; 334 | } 335 | handleBuffer(this.tui, buffer); 336 | // console.log(buffer); 337 | // this.tui.emit("key", buffer[0]); 338 | if (this.state == "cancel") { 339 | break; 340 | } 341 | buffer = new Uint8Array(0); 342 | } 343 | } 344 | } 345 | 346 | function handleKeyInputNode(tui) { 347 | let onData = (b) => { 348 | let buffer = new Uint8Array(b.buffer); 349 | handleBuffer(tui, buffer); 350 | }; 351 | 352 | process.stdin.on("data", onData); 353 | process.stdin.resume(); 354 | } 355 | 356 | async function handleKeyboardInput(tui) { 357 | // const async handleKeyboardInput = (tui) => { 358 | if (globalThis.Deno) { 359 | Deno.stdin.setRaw(true); 360 | for await (const chunk of Deno.stdin.readable) { 361 | } 362 | } else { 363 | handleKeyInputNode(tui); 364 | } 365 | } 366 | 367 | export function print(string) { 368 | if (typeof process === "object") { 369 | process.stdout.write(string); // We can write without a trailing newline 370 | } else if (typeof Deno === "object") { 371 | Deno.stdout.writeSync(new TextEncoder().encode(string)); // We can write without a trailing newline 372 | } else { 373 | console.log(string); // We're in a browser. Newlines are mandated 374 | } 375 | } 376 | 377 | function createEnum(values) { 378 | const enumObject = {}; 379 | for (const val of values) { 380 | enumObject[val] = val; 381 | } 382 | return Object.freeze(enumObject); 383 | } 384 | 385 | const CursorVisibility = createEnum(["Visible", "Hidden"]); 386 | 387 | const AltScreenState = createEnum(["Active", "Inactive"]); 388 | 389 | const escape = (code) => { 390 | print("\x1b[" + code); 391 | }; 392 | 393 | const escape_seq = (code) => { 394 | return "\x1b[" + code; 395 | }; 396 | 397 | const enter_alt_screen = () => { 398 | escape("?1049h"); 399 | }; 400 | 401 | const exit_alt_screen = () => { 402 | escape("?1049l"); 403 | }; 404 | 405 | const clear_line = () => { 406 | escape("2K"); 407 | }; 408 | 409 | const cursor_up = (x) => { 410 | escape(`${x}` + "A"); 411 | }; 412 | 413 | const cursor_up_seq = (x) => { 414 | return escape_seq(`${x}` + "A"); 415 | }; 416 | 417 | const clear_line_seq = () => { 418 | return escape_seq("2K"); 419 | }; 420 | 421 | const move_cursor = (x, y) => { 422 | escape(`${x};${y}H`); 423 | }; 424 | 425 | const cursor_back = (x) => { 426 | escape(`${x}D`); 427 | }; 428 | 429 | const cursor_back_seq = (x) => { 430 | return escape_seq(`${x}` + "D"); 431 | } 432 | 433 | const show_cursor = () => { 434 | escape("?25h"); 435 | }; 436 | 437 | const hide_cursor = () => { 438 | escape("?25l"); 439 | }; 440 | 441 | const erase_display_seq = (x) => { 442 | escape(`${x}J`); 443 | }; 444 | 445 | const set_window_title_seq = (title) => { 446 | print("\x1b]2;" + title + "\x07"); 447 | }; 448 | 449 | const clear = () => { 450 | erase_display_seq(2); 451 | move_cursor(1, 1); 452 | }; 453 | 454 | // import * as fs from 'node:fs'; 455 | // function log(text) { 456 | // fs.appendFile("log.txt", text, (err) => {}); 457 | // } 458 | 459 | class Renderer extends EventEmitter { 460 | #buffer = ""; 461 | #width = 0; 462 | #height = 0; 463 | #last_render = ""; 464 | altscreen_state = AltScreenState.Inactive; 465 | #lines_rendered = 0; 466 | #cursor_visibility = CursorVisibility.Visible; 467 | #refresh_delay; 468 | #runner; 469 | #nextUpdateTimeout; 470 | 471 | constructor(refresh_delay, runner) { 472 | super(); 473 | this.#refresh_delay = refresh_delay; 474 | this.#runner = runner; 475 | } 476 | 477 | #render(string) { 478 | this.#buffer = string; 479 | } 480 | 481 | #tick() { 482 | let now = 10; 483 | if (this.#isEmpty() || this.#sameAsLastFlush()) { 484 | } else { 485 | this.#flush(); 486 | } 487 | const updateStep = () => { 488 | const renderer = this; 489 | this.#nextUpdateTimeout = setTimeout(() => { 490 | renderer.emit("tick", "tick"); 491 | }, this.#refresh_delay); 492 | }; 493 | 494 | updateStep(); 495 | // this.#runner.emit("tick", now); 496 | } 497 | 498 | #flush() { 499 | let new_lines = this.#lines(); 500 | let new_lines_this_flush = new_lines.length; 501 | let clear_sequence = new Array(); 502 | 503 | if (this.#lines_rendered > 0) { 504 | for (let i = this.#lines_rendered; i > 0; i--) { 505 | clear_sequence.push(clear_line_seq()); 506 | clear_sequence.push(cursor_up_seq(1)); 507 | } 508 | } 509 | // extra clear line to prevent phantom length lines 510 | clear_sequence.push(clear_line_seq()); 511 | 512 | let tmp = new_lines.join("\r\n"); 513 | // log(clear_sequence.join("") + tmp + "\r\n" + cursor_back_seq(100)); 514 | print(clear_sequence.join("") + tmp + "\r\n"); 515 | if (this.altscreen_state == AltScreenState.Active) { 516 | move_cursor(new_lines_this_flush, 0); 517 | } else { 518 | // log("moved cursor") 519 | cursor_back(this.#width); 520 | // cursor_back(100); 521 | // console.log(cursor_back_seq(100)) 522 | } 523 | 524 | this.#last_render = this.#buffer; 525 | this.#lines_rendered = new_lines_this_flush; 526 | this.#buffer = ""; 527 | } 528 | 529 | #handleSetCursorVisibility(new_cursor_visibility) { 530 | if (this.#cursor_visibility !== new_cursor_visibility) { 531 | if (new_cursor_visibility == CursorVisibility.Hidden) { 532 | hide_cursor(); 533 | } else { 534 | show_cursor(); 535 | } 536 | this.#cursor_visibility = new_cursor_visibility; 537 | } 538 | } 539 | 540 | internalHideCursor() { 541 | hide_cursor(); 542 | this.#cursor_visibility = CursorVisibility.Hidden; 543 | } 544 | 545 | internalShowCursor() { 546 | show_cursor(); 547 | this.#cursor_visibility = CursorVisibility.Visible; 548 | } 549 | 550 | handleEnterAltScreen() { 551 | if (this.altscreen_state == AltScreenState.Inactive) { 552 | enter_alt_screen(); 553 | clear(); 554 | this.altscreen_state = AltScreenState.Active; 555 | this.#last_render = ""; 556 | } 557 | } 558 | 559 | handleExitAltScreen() { 560 | if (this.altscreen_state == AltScreenState.Active) { 561 | exit_alt_screen(); 562 | this.altscreen_state = AltScreenState.Inactive; 563 | this.#last_render = ""; 564 | } 565 | } 566 | 567 | repaint() { 568 | this.#last_render = ""; 569 | } 570 | 571 | #restore() { 572 | if (this.#cursor_visibility == CursorVisibility.Hidden) { 573 | show_cursor(); 574 | } 575 | } 576 | 577 | #shutdown() { 578 | this.off(); 579 | clearTimeout(this.#nextUpdateTimeout); 580 | // this.#flush(); 581 | this.#restore(); 582 | } 583 | 584 | #isEmpty() { 585 | return this.#buffer === ""; 586 | } 587 | 588 | #sameAsLastFlush() { 589 | return this.#buffer === this.#last_render; 590 | } 591 | 592 | #lines() { 593 | if (this.#isEmpty()) { 594 | return []; 595 | } 596 | return this.#buffer.split("\n"); 597 | } 598 | 599 | run() { 600 | this.on("render", (string) => { 601 | this.#render(string); 602 | }); 603 | this.on("tick", (a) => { 604 | this.#tick(); 605 | }); 606 | this.on("cursor_visibility", (cursor_visibility) => { 607 | this.#handleSetCursorVisibility(cursor_visibility); 608 | }); 609 | this.on("shutdown", () => this.#shutdown()); 610 | this.on("enter_alt_screen", () => this.handleEnterAltScreen()); 611 | this.on("exit_alt_screen", () => this.handleExitAltScreen()); 612 | this.#tick(); 613 | } 614 | 615 | show_cursor() { 616 | this.emit("cursor_visibility", CursorVisibility.Visible); 617 | } 618 | hide_cursor() { 619 | this.emit("cursor_visibility", CursorVisibility.Hidden); 620 | } 621 | 622 | render(string) { 623 | this.emit("render", string); 624 | } 625 | 626 | shutdown() { 627 | this.emit("shutdown"); 628 | } 629 | 630 | enter_alt_screen() { 631 | this.emit("enter_alt_screen"); 632 | } 633 | 634 | exit_alt_screen() { 635 | this.emit("exit_alt_screen"); 636 | } 637 | } 638 | 639 | export class App extends EventEmitter { 640 | #init; 641 | #update; 642 | #view; 643 | #model; 644 | #renderer; 645 | #refreshDelay = 20; 646 | #initAltScreen = new AltScreenDisabled(); 647 | #denoListener; 648 | #pausedAltScreenState; 649 | #queue = []; 650 | #commands = []; 651 | 652 | #handleNewEvent(event) { 653 | this.#queue.push(event) 654 | this.#flush() 655 | } 656 | 657 | #flush() { 658 | if (this.#queue.length) { 659 | while (this.#queue.length) { 660 | this.#handleEvent(this.#queue.shift()) 661 | } 662 | } 663 | 664 | while (this.#commands.length) { 665 | this.#handleCommand(this.#commands.shift()) 666 | } 667 | 668 | if (this.#queue.length) { 669 | this.#flush() 670 | } 671 | } 672 | 673 | initializeTerminal() { 674 | if (globalThis.Deno) { 675 | Deno.stdin.setRaw(true); 676 | } else { 677 | process.stdin.setRawMode(true); 678 | } 679 | this.#renderer.internalHideCursor(); 680 | } 681 | 682 | createKeyboardListener() { 683 | if (globalThis.Deno) { 684 | this.#denoListener = handleKeyInputDeno(this); 685 | } else handleKeyInputNode(this); 686 | } 687 | 688 | #pauseKeyboardInput() { 689 | // console.log("paused"); 690 | if (globalThis.Deno) { 691 | this.#denoListener.emit("cancel"); 692 | this.#denoListener = null; 693 | } else { 694 | process.stdin.pause(); 695 | } 696 | } 697 | 698 | #resumeKeyboardInput() { 699 | // console.log("resumed"); 700 | if (globalThis.Deno) { 701 | this.createKeyboardListener(); 702 | } else { 703 | process.stdin.resume(); 704 | } 705 | } 706 | 707 | releaseTerminal() { 708 | this.#pauseKeyboardInput(); 709 | this.#renderer.shutdown(); 710 | this.#pausedAltScreenState = this.#renderer.altscreen_state; 711 | this.restoreTerminalState(); 712 | } 713 | 714 | restoreTerminal() { 715 | this.initializeTerminal(); 716 | this.#resumeKeyboardInput(); 717 | if (this.#pausedAltScreenState == AltScreenState.Active) { 718 | this.#renderer.handleEnterAltScreen(); 719 | } else { 720 | this.#renderer.repaint() 721 | } 722 | this.#renderer.run(); 723 | // resize 724 | if (globalThis.Deno) { 725 | let size = Deno.consoleSize(); 726 | this.#emitResizeEvent(size.columns, size.rows); 727 | } else { 728 | this.#emitResizeEvent(process.stdout.columns, process.stdout.rows); 729 | } 730 | } 731 | 732 | restoreTerminalState() { 733 | this.#renderer.internalShowCursor(); 734 | this.#renderer.handleExitAltScreen(); 735 | 736 | if (globalThis.Deno) { 737 | try { 738 | Deno.stdin.setRaw(false); 739 | } catch { 740 | /**/ 741 | } 742 | } else { 743 | process.stdin.setRawMode(false); 744 | } 745 | } 746 | 747 | setRefreshDelay(value) { 748 | this.#refreshDelay = value; 749 | return this; 750 | } 751 | 752 | setAltScreen() { 753 | this.#initAltScreen = new AltScreenEnabled(); 754 | return this; 755 | } 756 | 757 | constructor(init, update, view) { 758 | super(); 759 | this.#init = init; 760 | this.#update = update; 761 | this.#view = view; 762 | } 763 | 764 | run(flags) { 765 | // this.#refreshDelay = options.refresh_delay; 766 | this.dispatch(); 767 | const self = this; 768 | // handleKeyboardInput(self); 769 | this.#listenForResize(); 770 | 771 | const renderer = new Renderer(this.#refreshDelay, self); 772 | renderer.run(); 773 | this.#renderer = renderer; 774 | this.initializeTerminal(); 775 | this.createKeyboardListener(); 776 | 777 | if (this.#initAltScreen instanceof AltScreenEnabled) { 778 | this.#renderer.enter_alt_screen(); 779 | } 780 | 781 | let [model, init_command] = this.#init(flags); 782 | this.#handleCommand(init_command); 783 | 784 | this.#model = model; 785 | 786 | let view = this.#view(model); 787 | this.#renderer.render(view); 788 | 789 | this.on("custom_messages", (msg) => { 790 | let event = new CustomEvent(msg); 791 | this.#handleNewEvent(event); 792 | }); 793 | // this.on("tick", (int) => { 794 | // let frame = new Frame(int); 795 | // this.#handleNewEvent(frame); 796 | // }); 797 | this.on("effectDispatch", (msg) => { 798 | let event = new CustomEvent(msg); 799 | this.#handleNewEvent(event); 800 | }); 801 | this.on("terminalResize", (size) => { 802 | this.#handleNewEvent(new Resize(size.width, size.height)); 803 | }); 804 | this.on("keyPress", (key) => { 805 | let parsed_key = parse_key_type(key); 806 | let modifier_key = handle_modifier(key, parsed_key); 807 | 808 | this.#handleNewEvent(new KeyEvent(modifier_key)); 809 | }); 810 | this.on("timers", (msg) => { 811 | let event = new CustomEvent(msg); 812 | this.#handleNewEvent(event); 813 | }); 814 | if (globalThis.Deno) { 815 | let size = Deno.consoleSize(); 816 | this.#emitResizeEvent(size.columns, size.rows); 817 | } else { 818 | this.#emitResizeEvent(process.stdout.columns, process.stdout.rows); 819 | } 820 | 821 | return (action) => this.handleAction(action); 822 | } 823 | 824 | handleAction(action) { 825 | switch (true) { 826 | case action[0] instanceof Shutdown: 827 | this.emit("destroy"); 828 | break; 829 | case action[0] instanceof Send: 830 | let msg = action[0][0]; 831 | this.send(msg); 832 | break; 833 | case action[0] instanceof WindowTitle: 834 | let title = action[0][0]; 835 | set_window_title_seq(title); 836 | break; 837 | } 838 | } 839 | #handleEvent(event) { 840 | let [model, command] = this.#update(this.#model, event); 841 | let updated_view = this.#view(model); 842 | this.#handleNewCommand(command); 843 | this.#renderer.render(updated_view); 844 | this.#model = model; 845 | } 846 | 847 | #handleNewCommand(command) { 848 | this.#commands.push(command) 849 | } 850 | 851 | #setTimer(msg, duration) { 852 | setTimeout(() => { 853 | this.emit("timers", msg); 854 | }, duration.milliseconds); 855 | } 856 | 857 | #handleCommand(command) { 858 | switch (true) { 859 | case command[0] instanceof Quit: 860 | this.emit("destroy"); 861 | break; 862 | case command[0] instanceof Noop: 863 | break; 864 | case command[0] instanceof ExecuteProcess: 865 | let program = command[0][0]; 866 | let args = command[0][1].toArray(); 867 | this.releaseTerminal() 868 | child_process.spawnSync(program, args, {stdio: "inherit"}) 869 | this.restoreTerminal() 870 | break 871 | case command[0] instanceof ClearScreen: 872 | clear(); 873 | break; 874 | case command[0] instanceof SetTimer: 875 | let msg = command[0][0]; 876 | let duration = command[0][1]; 877 | this.#setTimer(msg, duration); 878 | break; 879 | case command[0] instanceof HideCursor: 880 | this.#renderer.hide_cursor(); 881 | break; 882 | case command[0] instanceof ShowCursor: 883 | this.#renderer.show_cursor(); 884 | break; 885 | case command[0] instanceof EnterAltScreen: 886 | this.#renderer.enter_alt_screen(); 887 | break; 888 | case command[0] instanceof ExitAltScreen: 889 | this.#renderer.exit_alt_screen(); 890 | break; 891 | case command[0] instanceof SetWindowTitle: 892 | let title = command[0][0]; 893 | set_window_title_seq(title); 894 | break; 895 | case command[0] instanceof Seq: 896 | let cmds = command[0][0]; 897 | for (const cmd of cmds) { 898 | this.#handleNewCommand(cmd); 899 | } 900 | break; 901 | case command[0] instanceof CustomCommand: 902 | let effect = command[0][0]; 903 | effect((msg) => this.effectDispatch(msg)); 904 | break; 905 | } 906 | } 907 | 908 | effectDispatch(msg) { 909 | this.emit("effectDispatch", msg); 910 | } 911 | 912 | send(msg) { 913 | this.emit("custom_messages", msg); 914 | } 915 | 916 | destroy() { 917 | this.off(); 918 | 919 | // this.#renderer.render(this.#view(this.#model)) 920 | this.#renderer.exit_alt_screen(); 921 | this.#renderer.shutdown(); 922 | 923 | if (globalThis.Deno) { 924 | try { 925 | Deno.stdin.setRaw(false); 926 | } catch { 927 | /**/ 928 | } 929 | } else { 930 | process.stdin.setRawMode(false); 931 | process.stdin.pause(); 932 | } 933 | } 934 | 935 | #emitResizeEvent(width, height) { 936 | this.emit("terminalResize", { width: width, height: height }); 937 | } 938 | 939 | #listenForResize() { 940 | if (globalThis.Deno) { 941 | if (Deno.build.os === "windows") { 942 | setInterval(() => { 943 | let size = Deno.consoleSize(); 944 | this.#emitResizeEvent(size.columns, size.rows); 945 | }, this.#refreshDelay); 946 | } else { 947 | Deno.addSignalListener("SIGWINCH", () => { 948 | let size = Deno.consoleSize(); 949 | this.#emitResizeEvent(size.columns, size.rows); 950 | }); 951 | } 952 | } else { 953 | if (process.platform == "win32") { 954 | setInterval(() => { 955 | this.#emitResizeEvent(process.stdout.columns, process.stdout.rows); 956 | }, this.#refreshDelay); 957 | } else { 958 | process.on("SIGWINCH", () => { 959 | this.#emitResizeEvent(process.stdout.columns, process.stdout.rows); 960 | }); 961 | } 962 | } 963 | } 964 | 965 | dispatch() { 966 | const destroyDispatcher = () => { 967 | this.emit("destroy"); 968 | }; 969 | 970 | if (globalThis.Deno) { 971 | if (Deno.build.os === "windows") { 972 | Deno.addSignalListener("SIGBREAK", destroyDispatcher); 973 | 974 | this.on("keyPress", ({ key, ctrl }) => { 975 | if (ctrl && key === "c") destroyDispatcher(); 976 | }); 977 | } else { 978 | Deno.addSignalListener("SIGTERM", destroyDispatcher); 979 | } 980 | 981 | Deno.addSignalListener("SIGINT", destroyDispatcher); 982 | } else { 983 | process.on("SIGTERM", destroyDispatcher); 984 | process.on("SIGINT", destroyDispatcher); 985 | } 986 | const exit = () => { 987 | if (globalThis.Deno) { 988 | Deno.exit(0); 989 | } else { 990 | process.exit(0); 991 | } 992 | }; 993 | 994 | this.on("destroy", async () => { 995 | this.destroy(); 996 | await Promise.resolve(); 997 | exit(); 998 | }); 999 | } 1000 | } 1001 | 1002 | export const setup = (init, update, view) => new App(init, update, view); 1003 | export const run = (app, flags) => app.run(flags); 1004 | export const set_refresh_delay = (app, refresh_delay) => 1005 | app.setRefreshDelay(refresh_delay); 1006 | export const set_alt_screen = (app) => app.setAltScreen(); 1007 | --------------------------------------------------------------------------------