Simplify input capture

Instead of "injecting" characters into the input stream, the input
stream is now forwarded to the `musicplayer`. It has will have to
decide what to do with the stream, e.g. by setting the "mode" to
something that captures the input stream and acts upon it
This commit is contained in:
Alexander Heldt
2025-11-28 23:32:45 +01:00
parent 35d331a753
commit 9a7ae0bfe4
6 changed files with 98 additions and 103 deletions

View File

@@ -8,15 +8,13 @@ import musicplayer/ui/ui
pub fn main() -> Nil { pub fn main() -> Nil {
let input_keys_name: Name(Key) = process.new_name("input_keys") let input_keys_name: Name(Key) = process.new_name("input_keys")
let input_inject_name: Name(Key) = process.new_name("input_inject_keys")
input.new(input_keys_name, input_inject_name) input.new(input_keys_name)
let assert Ok(ui) = ui.new() let assert Ok(ui) = ui.new()
let assert Ok(mpv) = mpv.new() let assert Ok(mpv) = mpv.new()
let exit = process.new_subject() let exit = process.new_subject()
let assert Ok(_) = let assert Ok(_) = musicplayer.new(ui, mpv, input_keys_name, exit)
musicplayer.new(ui, mpv, input_keys_name, input_inject_name, exit)
process.receive_forever(exit) process.receive_forever(exit)
} }

View File

@@ -2,26 +2,29 @@ import musicplayer/input/key.{type Key}
pub type Control { pub type Control {
TogglePlayPause TogglePlayPause
Search
Input(String) Raw(String)
Search(List(String)) Return
Backspace
Exit Exit
} }
pub fn from_key(key: Key) -> Result(Control, Nil) { pub fn from_key(key: Key) -> Result(Control, Nil) {
case key { case key {
key.Input(content) -> Ok(Input(content)) key.Return -> Ok(Return)
key.Char(char) -> char_control(char) key.Backspace -> Ok(Backspace)
key.Char(char) -> Ok(char_control(char))
_ -> Error(Nil) _ -> Error(Nil)
} }
} }
fn char_control(char: String) -> Result(Control, Nil) { fn char_control(char: String) -> Control {
case char { case char {
" " -> Ok(TogglePlayPause) " " -> TogglePlayPause
"/" -> Ok(Search([])) "/" -> Search
"q" -> Ok(Exit) "q" -> Exit
_ -> Error(Nil) _ -> Raw(char)
} }
} }

View File

@@ -2,22 +2,12 @@ import gleam/erlang/process.{type Name}
import musicplayer/input/key.{type Key} import musicplayer/input/key.{type Key}
/// `new` accepts two named subjects: /// `new` accepts a subject that all input will be sent to
/// - one to send the input to, and pub fn new(input_keys_name: Name(Key)) -> Nil {
/// - one to receive injected input from
///
/// The subject for input injection is used 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
pub fn new(input_keys_name: Name(Key), input_inject_name: Name(Key)) -> Nil {
let _ = let _ =
process.spawn(fn() { process.spawn(fn() {
let input_keys = process.named_subject(input_keys_name) let input_keys = process.named_subject(input_keys_name)
let input_inject = process.named_subject(input_inject_name) key.read_input_until_key(input_keys)
let assert Ok(_) = process.register(process.self(), input_inject_name)
key.read_input_until_key(input_keys, input_inject)
}) })
key.start_raw_shell() key.start_raw_shell()

View File

@@ -1,20 +1,21 @@
import gleam/erlang/atom import gleam/erlang/atom
import gleam/erlang/process.{type Subject} import gleam/erlang/process.{type Subject}
import gleam/list import gleam/list
import gleam/string
import musicplayer/input/internal as internal_input import musicplayer/input/internal as internal_input
pub type Key { pub type Key {
Continue(buffer: List(String))
Char(String) Char(String)
Input(String)
Left Left
Right Right
Up Up
Down Down
Continue(buffer: List(String)) Return
Backspace
Unknown Unknown
} }
@@ -23,8 +24,9 @@ pub const esc = "\u{001B}"
// control sequence introducer // control sequence introducer
pub const csi = "[" pub const csi = "["
// input introducer pub const return = "\r"
pub const input_introducer = "::"
pub const backspace = "\u{007F}"
pub fn from_list(l: List(String)) -> Key { pub fn from_list(l: List(String)) -> Key {
case l { case l {
@@ -35,18 +37,9 @@ pub fn from_list(l: List(String)) -> Key {
[e, c] if e == esc && c == csi -> Continue(l) [e, c] if e == esc && c == csi -> Continue(l)
[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) [e] if e == esc -> Continue(l)
[bs] if bs == backspace -> Backspace
[rtn] if rtn == return -> Return
[char] -> Char(char) [char] -> Char(char)
[] -> Continue([]) [] -> Continue([])
@@ -60,35 +53,24 @@ pub fn start_raw_shell() {
internal_input.shell_start_interactive(#(no_shell, raw)) internal_input.shell_start_interactive(#(no_shell, raw))
} }
pub fn read_input_until_key( pub fn read_input_until_key(input_keys: Subject(Key)) -> Nil {
read_input_until_key_loop([], input_keys)
}
pub fn read_input_until_key_loop(
l: List(String),
input_keys: Subject(Key), input_keys: Subject(Key),
input_inject: Subject(Key),
) -> Nil { ) -> Nil {
case process.receive(input_inject, 1) {
Ok(Continue(buffer)) -> buffer
Ok(_) | Error(_) -> []
}
|> forward_input_to(input_keys)
read_input_until_key(input_keys, input_inject)
}
pub fn forward_input_to(l: List(String), input_keys: Subject(Key)) -> Nil {
case case
internal_input.read_input() internal_input.read_input()
|> list.wrap |> list.wrap
|> list.append(l, _) |> list.append(l, _)
|> from_list |> from_list
{ {
Continue(l) -> { Continue(l) -> read_input_until_key_loop(l, input_keys)
case l { k -> {
[ii, cmd] if ii == input_introducer -> { process.send(input_keys, k)
process.send(input_keys, Continue([cmd])) read_input_until_key_loop([], input_keys)
forward_input_to(l, input_keys)
}
_ -> forward_input_to(l, input_keys)
} }
} }
k -> process.send(input_keys, k)
}
} }

View File

@@ -10,11 +10,21 @@ import musicplayer/mpv/control as mpv_control
import musicplayer/ui/control as ui_control import musicplayer/ui/control as ui_control
import musicplayer/ui/layout import musicplayer/ui/layout
type State(ui, mpv, input_inject, exit) { type Mode {
Idle
Searching
}
type Input {
Input(capturing: Bool, content: String)
}
type State {
State( State(
mode: Mode,
input: Input,
ui: Subject(ui_control.Control), ui: Subject(ui_control.Control),
mpv: Subject(mpv_control.Control), mpv: Subject(mpv_control.Control),
input_inject: Subject(Key),
exit: Subject(Nil), exit: Subject(Nil),
) )
} }
@@ -23,14 +33,14 @@ pub fn new(
ui: Subject(ui_control.Control), ui: Subject(ui_control.Control),
mpv: Subject(mpv_control.Control), mpv: Subject(mpv_control.Control),
input_keys_name: Name(Key), input_keys_name: Name(Key),
input_inject_name: Name(Key),
exit: Subject(Nil), exit: Subject(Nil),
) -> Result(Nil, String) { ) -> Result(Nil, String) {
let input_keys = process.named_subject(input_keys_name) let input_keys = process.named_subject(input_keys_name)
let input_inject = process.named_subject(input_inject_name)
let input = Input(False, "")
case case
actor.new(State(ui, mpv, input_inject, exit)) actor.new(State(Idle, input, ui, mpv, exit))
|> actor.on_message(handle_message) |> actor.on_message(handle_message)
|> actor.start |> actor.start
{ {
@@ -49,28 +59,53 @@ pub fn new(
} }
} }
fn handle_message( fn handle_message(state: State, control: Control) -> actor.Next(State, Control) {
state: State(ui, mpv, input_inject, exit),
control: Control,
) -> actor.Next(State(ui, mpv, input_inject, exit), Control) {
case control { case control {
control.Search([]) -> { control.Search -> {
process.send(state.input_inject, key.Continue([key.input_introducer]))
update_search(state.ui, "searching: ") update_search(state.ui, "searching: ")
actor.continue(state)
actor.continue(
State(
..state,
mode: Searching,
input: Input(..state.input, capturing: True),
),
)
} }
control.Search(content) -> {
update_search(state.ui, "searching: " <> string.join(content, "")) control.Raw(content) -> {
actor.continue(state) let content = case state.mode {
Idle -> state.input.content
Searching -> {
let updated = state.input.content <> content
update_search(state.ui, "searching: " <> updated)
updated
} }
control.Input(_) -> { }
update_search(state.ui, "")
actor.continue(state) actor.continue(State(..state, input: Input(..state.input, content:)))
}
control.Backspace -> {
let content = case state.mode {
Idle -> state.input.content
Searching -> string.drop_end(state.input.content, 1)
}
actor.continue(State(..state, input: Input(..state.input, content:)))
}
control.Return -> {
// Note: state.input.content is now the final input, use it
// before it is reset
case state.mode {
Idle -> Nil
Searching -> update_search(state.ui, "")
}
actor.continue(
State(..state, mode: Idle, input: Input(capturing: False, content: "")),
)
} }
control.TogglePlayPause -> { control.TogglePlayPause -> {
echo "toggling play/pause"
process.send(state.mpv, mpv_control.TogglePlayPause) process.send(state.mpv, mpv_control.TogglePlayPause)
update_playback_time(state.mpv, state.ui) update_playback_time(state.mpv, state.ui)
actor.continue(state) actor.continue(state)
@@ -133,24 +168,14 @@ fn update_search(ui: Subject(ui_control.Control), content: String) -> Nil {
process.send(ui, ui_control.UpdateState(layout.Search, content)) process.send(ui, ui_control.UpdateState(layout.Search, content))
} }
/// `handle_key` listens to a subject onto which `input` will send messages with /// `handle_key` listens to a subject onto which `input` will send messages with `Key`s
/// `Key`s. The keys can be either be
/// - instances of `Continue` which are "streamed"input, or
/// - instances of "final" keys that are the result of "end of input"
fn handle_key(musicplayer: Subject(Control), input_keys: Subject(Key)) -> Nil { fn handle_key(musicplayer: Subject(Control), input_keys: Subject(Key)) -> Nil {
let _ = case let _ =
process.new_selector() process.new_selector()
|> process.select(input_keys) |> process.select(input_keys)
|> process.selector_receive_forever |> process.selector_receive_forever
{
key.Continue(buffer:) ->
Ok(process.send(musicplayer, control.Search(buffer)))
key ->
key
|> control.from_key |> control.from_key
|> result.map(process.send(musicplayer, _)) |> result.map(process.send(musicplayer, _))
}
handle_key(musicplayer, input_keys) handle_key(musicplayer, input_keys)
} }

View File

@@ -1,7 +1,7 @@
import gleam/list import gleam/list
import gleeunit import gleeunit
import musicplayer/input/key.{type Key, Char, csi, esc, input_introducer as ii} import musicplayer/input/key.{type Key, Char, backspace, csi, esc, return}
pub fn main() -> Nil { pub fn main() -> Nil {
gleeunit.main() gleeunit.main()
@@ -27,11 +27,8 @@ pub fn key_from_list_test() {
] ]
let input_tests = [ let input_tests = [
TestCase([ii], key.Continue([ii])), TestCase([return], key.Return),
TestCase([ii, "a"], key.Continue([ii, "a"])), TestCase([backspace], key.Backspace),
TestCase([ii, "a", "b"], key.Continue([ii, "ab"])),
TestCase([ii, "ab", "\u{007F}"], key.Continue([ii, "a"])),
TestCase([ii, "ab", "\r"], key.Input("ab")),
] ]
let test_cases = [base_tests, char_tests, escape_tests, input_tests] let test_cases = [base_tests, char_tests, escape_tests, input_tests]