From 78cc3647c7f084d56c053124c4ce241a834f14ed Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 16 Nov 2025 20:38:16 +0100 Subject: [PATCH 1/4] Correct `io_get_chars` comment/documentation --- src/input/internal.gleam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/internal.gleam b/src/input/internal.gleam index bb8fbc1..cf1f7f9 100644 --- a/src/input/internal.gleam +++ b/src/input/internal.gleam @@ -10,6 +10,6 @@ pub type NotUsed @external(erlang, "shell", "start_interactive") pub fn shell_start_interactive(options: #(atom.Atom, atom.Atom)) -> NotUsed -// https://www.erlang.org/doc/apps/stdlib/io.html#get_line/1 +// https://www.erlang.org/doc/apps/stdlib/io.html#get_chars/2 @external(erlang, "io", "get_chars") fn io_get_chars(prompt: String, count: Int) -> String -- 2.51.0 From 4d935a2e29fc756536384c1586a930bce20f93de Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 16 Nov 2025 20:37:34 +0100 Subject: [PATCH 2/4] Add ability to create character sequences as `Input` --- src/input/key.gleam | 35 +++++++++++++++++++++++++++-------- src/mpv/control.gleam | 5 +++-- test/input/input_test.gleam | 26 +++++++++++++++++++------- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/input/key.gleam b/src/input/key.gleam index 12fd6cb..ffc92c6 100644 --- a/src/input/key.gleam +++ b/src/input/key.gleam @@ -1,17 +1,19 @@ import gleam/erlang/atom import gleam/list +import gleam/string import input/internal as internal_input pub type Key { Char(String) + Input(String) Left Right Up Down - Continue + Continue(buffer: List(String)) Unknown } @@ -20,6 +22,9 @@ pub const esc = "\u{001B}" // control sequence introducer pub const csi = "[" +// input introducer +pub const input_introducer = "::" + pub fn from_list(l: List(String)) -> Key { case l { [e, c, "D"] if e == esc && c == csi -> Left @@ -27,12 +32,23 @@ pub fn from_list(l: List(String)) -> Key { [e, c, "A"] if e == esc && c == csi -> Up [e, c, "B"] if e == esc && c == csi -> Down - [e, c] if e == esc && c == csi -> Continue + [e, c] if e == esc && c == csi -> Continue(l) - [e] if e == esc -> Continue + [ci] | [ci, _] if ci == input_introducer -> Continue(l) + [ii, cmd, tail] if ii == input_introducer -> { + case tail { + // Return + "\r" -> Input(cmd) + // Backspace + "\u{007F}" -> Continue([ii, string.drop_end(cmd, 1)]) + _ -> Continue([ii, cmd <> tail]) + } + } + + [e] if e == esc -> Continue(l) [char] -> Char(char) - [] -> Continue + [] -> Continue([]) _ -> Unknown } } @@ -44,10 +60,13 @@ pub fn start_raw_shell() { } pub fn read_input_until_key(l: List(String)) -> Key { - let l = internal_input.read_input() |> list.wrap |> list.append(l, _) - - case from_list(l) { - Continue -> read_input_until_key(l) + case + internal_input.read_input() + |> list.wrap + |> list.append(l, _) + |> from_list + { + Continue(l) -> read_input_until_key(l) k -> k } } diff --git a/src/mpv/control.gleam b/src/mpv/control.gleam index 7f63d9d..690beaf 100644 --- a/src/mpv/control.gleam +++ b/src/mpv/control.gleam @@ -2,13 +2,14 @@ import gleam/json import gleam/result import gleam/string -import input/key.{type Key, Char} +import input/key.{type Key} import mpv/internal/control as internal_control import tcp/reason.{type Reason} import tcp/tcp.{type Socket} pub type Control { TogglePlayPause + Exit } @@ -18,7 +19,7 @@ pub type ControlError { pub fn from_key(key: Key) -> Result(Control, Nil) { case key { - Char(char) -> char_control(char) + key.Char(char) -> char_control(char) _ -> Error(Nil) } } diff --git a/test/input/input_test.gleam b/test/input/input_test.gleam index 3777af5..2863aa1 100644 --- a/test/input/input_test.gleam +++ b/test/input/input_test.gleam @@ -1,7 +1,7 @@ import gleam/list import gleeunit -import input/key.{type Key, Char, csi, esc} +import input/key.{type Key, Char, csi, esc, input_introducer as ii} pub fn main() -> Nil { gleeunit.main() @@ -12,18 +12,30 @@ type TestCase { } pub fn key_from_list_test() { - let test_cases = [ - TestCase(["c"], Char("c")), + let base_tests = [TestCase([], key.Continue([]))] + + let char_tests = [TestCase(["c"], Char("c"))] + + let escape_tests = [ + TestCase([esc, csi], key.Continue([esc, csi])), + TestCase([esc], key.Continue([esc])), + TestCase([esc, csi, "D"], key.Left), TestCase([esc, csi, "C"], key.Right), TestCase([esc, csi, "A"], key.Up), TestCase([esc, csi, "B"], key.Down), - TestCase([esc, csi], key.Continue), - TestCase([esc], key.Continue), - TestCase([], key.Continue), ] - list.each(test_cases, fn(tc) { + let input_tests = [ + TestCase([ii], key.Continue([ii])), + TestCase([ii, "a"], key.Continue([ii, "a"])), + TestCase([ii, "a", "b"], key.Continue([ii, "ab"])), + TestCase([ii, "ab", "\r"], key.Input("ab")), + ] + + let test_cases = [base_tests, char_tests, escape_tests, input_tests] + + list.each(list.flatten(test_cases), fn(tc) { assert tc.expected == key.from_list(tc.input) }) } -- 2.51.0 From a6ac9eb5f7255b376bef4d222024766322345306 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Wed, 19 Nov 2025 17:51:48 +0100 Subject: [PATCH 3/4] Add ability to inject characters into the `input` --- src/mpv/mpv.gleam | 50 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/mpv/mpv.gleam b/src/mpv/mpv.gleam index ab8d2b1..c693bcb 100644 --- a/src/mpv/mpv.gleam +++ b/src/mpv/mpv.gleam @@ -4,13 +4,13 @@ import gleam/otp/actor import gleam/result import gleam/string -import input/key +import input/key.{type Key} import mpv/control.{type Control} import tcp/reason import tcp/tcp.{type Socket} -type State(socket, exit) { - State(socket: Socket, exit: Subject(Nil)) +type State(socket, inject_input, exit) { + State(socket: Socket, inject_input: Subject(Key), exit: Subject(Nil)) } pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { @@ -20,8 +20,14 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { case tcp.connect(socket_path) { Error(r) -> Error("Could not connect to mpv: " <> reason.to_string(r)) Ok(socket) -> { + // `inject_input` is created by name to allow the process that + // owns `read_input` to be able to register it, while the agent + // also have a reference to it to be able to inject input + let inject_input_name = process.new_name("inject_input") + let inject_input = process.named_subject(inject_input_name) + case - actor.new(State(socket, exit)) + actor.new(State(socket, inject_input, exit)) |> actor.on_message(handle_message) |> actor.start { @@ -30,7 +36,14 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { Ok(actor.Started(data:, ..)) -> { echo "waiting for input" key.start_raw_shell() - process.spawn(fn() { read_input(data) }) + + process.spawn(fn() { + let assert Ok(_) = + process.register(process.self(), inject_input_name) + + read_input(data, inject_input) + }) + Ok(Nil) } } @@ -39,9 +52,9 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { } fn handle_message( - state: State(socket, exit), + state: State(socket, inject, exit), control: Control, -) -> actor.Next(State(socket, exit), Control) { +) -> actor.Next(State(socket, inject, exit), Control) { case control { control.TogglePlayPause -> { echo "toggling play/pause" @@ -65,14 +78,21 @@ fn handle_message( } } -fn read_input(subject: Subject(Control)) -> Nil { - case - key.read_input_until_key([]) - |> control.from_key - { - Error(_) -> Nil - Ok(control) -> process.send(subject, control) +/// `read_input` operates by reading from input until a `Key` can be created. +/// It is possible to create a `Key` without the users input by sending +/// messages to `inject_input` which will initialize the "input to key" sequence. +/// This is useful to ultimately create a `Control` without the user having to +/// input all of the character(s) needed. +fn read_input(subject: Subject(Control), inject_input: Subject(Key)) -> Nil { + let buffer = case process.receive(inject_input, 1) { + Ok(key.Continue(buffer)) -> buffer + Ok(_) | Error(_) -> [] } - read_input(subject) + let _ = + key.read_input_until_key(buffer) + |> control.from_key + |> result.map(process.send(subject, _)) + + read_input(subject, inject_input) } -- 2.51.0 From 747f76a584415dde0052d0fd6a190eaf5ba60aba Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Wed, 19 Nov 2025 17:42:01 +0100 Subject: [PATCH 4/4] Add ability to listen (tap) the `input` By doing something like ``` fn input_output_loop(input_output: Subject(List(String))) -> Nil { let output = process.receive_forever(input_output) echo output input_output_loop(input_output) } ``` --- src/input/key.gleam | 11 +++++++++-- src/mpv/mpv.gleam | 30 +++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/input/key.gleam b/src/input/key.gleam index ffc92c6..53ba9bc 100644 --- a/src/input/key.gleam +++ b/src/input/key.gleam @@ -1,4 +1,5 @@ import gleam/erlang/atom +import gleam/erlang/process.{type Subject} import gleam/list import gleam/string @@ -59,14 +60,20 @@ pub fn start_raw_shell() { internal_input.shell_start_interactive(#(no_shell, raw)) } -pub fn read_input_until_key(l: List(String)) -> Key { +pub fn read_input_until_key( + l: List(String), + tap_input: Subject(List(String)), +) -> Key { case internal_input.read_input() |> list.wrap |> list.append(l, _) |> from_list { - Continue(l) -> read_input_until_key(l) + Continue(l) -> { + process.send(tap_input, l) + read_input_until_key(l, tap_input) + } k -> k } } diff --git a/src/mpv/mpv.gleam b/src/mpv/mpv.gleam index c693bcb..7401eee 100644 --- a/src/mpv/mpv.gleam +++ b/src/mpv/mpv.gleam @@ -9,8 +9,13 @@ import mpv/control.{type Control} import tcp/reason import tcp/tcp.{type Socket} -type State(socket, inject_input, exit) { - State(socket: Socket, inject_input: Subject(Key), exit: Subject(Nil)) +type State(socket, inject_input, tap_input, exit) { + State( + socket: Socket, + inject_input: Subject(Key), + tap_input: Subject(List(String)), + exit: Subject(Nil), + ) } pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { @@ -26,8 +31,11 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { let inject_input_name = process.new_name("inject_input") let inject_input = process.named_subject(inject_input_name) + let tap_input_name = process.new_name("tap_input") + let tap_input = process.named_subject(tap_input_name) + case - actor.new(State(socket, inject_input, exit)) + actor.new(State(socket, inject_input, tap_input, exit)) |> actor.on_message(handle_message) |> actor.start { @@ -41,7 +49,7 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { let assert Ok(_) = process.register(process.self(), inject_input_name) - read_input(data, inject_input) + read_input(data, inject_input, tap_input) }) Ok(Nil) @@ -52,9 +60,9 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { } fn handle_message( - state: State(socket, inject, exit), + state: State(socket, inject, input_output, exit), control: Control, -) -> actor.Next(State(socket, inject, exit), Control) { +) -> actor.Next(State(socket, inject, input_output, exit), Control) { case control { control.TogglePlayPause -> { echo "toggling play/pause" @@ -83,16 +91,20 @@ fn handle_message( /// messages to `inject_input` which will initialize the "input to key" sequence. /// This is useful to ultimately create a `Control` without the user having to /// input all of the character(s) needed. -fn read_input(subject: Subject(Control), inject_input: Subject(Key)) -> Nil { +fn read_input( + subject: Subject(Control), + inject_input: Subject(Key), + tap_input: Subject(List(String)), +) -> Nil { let buffer = case process.receive(inject_input, 1) { Ok(key.Continue(buffer)) -> buffer Ok(_) | Error(_) -> [] } let _ = - key.read_input_until_key(buffer) + key.read_input_until_key(buffer, tap_input) |> control.from_key |> result.map(process.send(subject, _)) - read_input(subject, inject_input) + read_input(subject, inject_input, tap_input) } -- 2.51.0