From 9aceff2c7e9c4548cbee6312789b7ba5bb33ae31 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 20 Nov 2025 17:00:36 +0100 Subject: [PATCH] Extract reading of input i/o to `input` And add the ability of other modules to listen to either the final result (a `Key`) or tap into the input as it is read --- src/input/input.gleam | 59 +++++++++++++++++++ src/input/key.gleam | 6 +- .../control.gleam => internal.gleam} | 0 src/mpv/mpv.gleam | 57 ++++++------------ src/musicplayer.gleam | 18 +++++- 5 files changed, 96 insertions(+), 44 deletions(-) create mode 100644 src/input/input.gleam rename src/mpv/{internal/control.gleam => internal.gleam} (100%) diff --git a/src/input/input.gleam b/src/input/input.gleam new file mode 100644 index 0000000..3e8cace --- /dev/null +++ b/src/input/input.gleam @@ -0,0 +1,59 @@ +import gleam/erlang/process.{type Name, type Subject} +import gleam/list +import gleam/option.{type Option, None, Some} + +import input/key.{type Key} + +pub type Listener { + InputListener(final: Subject(Key), tap: Option(Subject(List(String)))) +} + +/// `new` accepts a list of listeners that are composed of two subjects; +/// - one to get the final `Key` and +/// - one to tap the input as it is read from i/o +/// and +/// - a subject name that is used to create a `Subject` that other processes +/// know they can inject a `Key` into the input with +pub fn new(listeners: List(Listener), inject_input_name: Name(Key)) -> Nil { + let _ = + process.spawn(fn() { + let inject_input: Subject(Key) = process.named_subject(inject_input_name) + let assert Ok(_) = process.register(process.self(), inject_input_name) + + // Extract all finals and taps (that are defined) + let #(finals, taps) = + list.fold(listeners, #([], []), fn(acc, listener) { + let #(finals, taps) = acc + + let finals = [listener.final, ..finals] + + let taps = case listener.tap { + Some(t) -> [t, ..taps] + None -> taps + } + + #(finals, taps) + }) + + read_input(finals, taps, inject_input) + }) + + Nil +} + +fn read_input( + finals: List(Subject(Key)), + taps: List(Subject(List(String))), + inject_input: Subject(Key), +) -> Nil { + let buffer = case process.receive(inject_input, 1) { + Ok(key.Continue(buffer)) -> buffer + Ok(_) | Error(_) -> [] + } + + let _ = + key.read_input_until_key(buffer, taps) + |> fn(k) { list.each(finals, process.send(_, k)) } + + read_input(finals, taps, inject_input) +} diff --git a/src/input/key.gleam b/src/input/key.gleam index 53ba9bc..ebb7c2f 100644 --- a/src/input/key.gleam +++ b/src/input/key.gleam @@ -62,7 +62,7 @@ pub fn start_raw_shell() { pub fn read_input_until_key( l: List(String), - tap_input: Subject(List(String)), + taps: List(Subject(List(String))), ) -> Key { case internal_input.read_input() @@ -71,8 +71,8 @@ pub fn read_input_until_key( |> from_list { Continue(l) -> { - process.send(tap_input, l) - read_input_until_key(l, tap_input) + list.each(taps, process.send(_, l)) + read_input_until_key(l, taps) } k -> k } diff --git a/src/mpv/internal/control.gleam b/src/mpv/internal.gleam similarity index 100% rename from src/mpv/internal/control.gleam rename to src/mpv/internal.gleam diff --git a/src/mpv/mpv.gleam b/src/mpv/mpv.gleam index 7401eee..5b256a9 100644 --- a/src/mpv/mpv.gleam +++ b/src/mpv/mpv.gleam @@ -1,41 +1,32 @@ import gleam/erlang/process.{type Subject} import gleam/float +import gleam/option.{None} import gleam/otp/actor import gleam/result import gleam/string +import input/input.{type Listener, InputListener} import input/key.{type Key} import mpv/control.{type Control} import tcp/reason import tcp/tcp.{type Socket} -type State(socket, inject_input, tap_input, exit) { - State( - socket: Socket, - inject_input: Subject(Key), - tap_input: Subject(List(String)), - exit: Subject(Nil), - ) +type State(socket, exit) { + State(socket: Socket, exit: Subject(Nil)) } -pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { +pub fn new(exit: Subject(Nil)) -> Result(Listener, String) { // TODO start up mvp here, currently hi-jacking `naviterm`s socket let socket_path = "/tmp/naviterm_mpv" 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) - - let tap_input_name = process.new_name("tap_input") - let tap_input = process.named_subject(tap_input_name) + let final_input_name = process.new_name("mpv_final_input") + let final_input: Subject(Key) = process.named_subject(final_input_name) case - actor.new(State(socket, inject_input, tap_input, exit)) + actor.new(State(socket, exit)) |> actor.on_message(handle_message) |> actor.start { @@ -47,12 +38,12 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { process.spawn(fn() { let assert Ok(_) = - process.register(process.self(), inject_input_name) + process.register(process.self(), final_input_name) - read_input(data, inject_input, tap_input) + handle_key(final_input, data) }) - Ok(Nil) + Ok(InputListener(final: final_input, tap: None)) } } } @@ -60,9 +51,9 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { } fn handle_message( - state: State(socket, inject, input_output, exit), + state: State(socket, exit), control: Control, -) -> actor.Next(State(socket, inject, input_output, exit), Control) { +) -> actor.Next(State(socket, exit), Control) { case control { control.TogglePlayPause -> { echo "toggling play/pause" @@ -86,25 +77,13 @@ fn handle_message( } } -/// `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), - tap_input: Subject(List(String)), -) -> Nil { - let buffer = case process.receive(inject_input, 1) { - Ok(key.Continue(buffer)) -> buffer - Ok(_) | Error(_) -> [] - } - +/// `handle_key` listens to a subject onto which `input` will send messages with +/// parsed `Key`s which will be mapped to `Control`s (if possible) +fn handle_key(final_input: Subject(Key), subject: Subject(Control)) -> Nil { let _ = - key.read_input_until_key(buffer, tap_input) + process.receive_forever(final_input) |> control.from_key |> result.map(process.send(subject, _)) - read_input(subject, inject_input, tap_input) + handle_key(final_input, subject) } diff --git a/src/musicplayer.gleam b/src/musicplayer.gleam index 717142d..98151d8 100644 --- a/src/musicplayer.gleam +++ b/src/musicplayer.gleam @@ -1,8 +1,22 @@ -import gleam/erlang/process +import gleam/erlang/process.{type Name} + +import input/input.{type Listener} +import input/key.{type Key} import mpv/mpv pub fn main() -> Nil { let exit = process.new_subject() - let assert Ok(_) = mpv.new(exit) + + // `inject_input` is created by name to allow the `input` process that + // owns `read_input` to be able to register and receive from it, + // while the any other processes can use the name reference to + // inject input + let inject_input_name: Name(Key) = process.new_name("inject_input") + + let assert Ok(mpv_listener) = mpv.new(exit) + + let listeners: List(Listener) = [mpv_listener] + + input.new(listeners, inject_input_name) process.receive_forever(exit) }