From 0ef94d7c89bfb42701c57c9f9ef4e2b7f2ff82fe Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 25 Dec 2025 17:49:34 +0100 Subject: [PATCH] Key handling is state aware By forwarding all `Key`s to the agent and allow it to decide what should be done, instead of converting the `Key` to a `Control` and then decide what should be done --- src/musicplayer/control.gleam | 51 +++++++--- src/musicplayer/musicplayer.gleam | 151 ++++++++++------------------ src/musicplayer/ui/layout.gleam | 9 +- src/musicplayer/ui/plot.gleam | 1 + test/musicplayer/control_test.gleam | 22 ++-- 5 files changed, 107 insertions(+), 127 deletions(-) diff --git a/src/musicplayer/control.gleam b/src/musicplayer/control.gleam index 386ceeb..3246f00 100644 --- a/src/musicplayer/control.gleam +++ b/src/musicplayer/control.gleam @@ -1,30 +1,51 @@ +import gleam/string + import musicplayer/input/key.{type Key} +pub type Mode { + Idle + Searching(input: String) +} + pub type Control { TogglePlayPause - Search - - Raw(String) - Return - Backspace + Search(input: String, capturing: Bool) Exit } -pub fn from_key(key: Key) -> Result(Control, Nil) { +pub fn from_key(key: Key, mode: Mode) -> Result(Control, Nil) { + case mode { + Idle -> idle_from_key(key) + Searching(input) -> searching_from_key(key, input) + } +} + +pub fn idle_from_key(key: Key) -> Result(Control, Nil) { case key { - key.Return -> Ok(Return) - key.Backspace -> Ok(Backspace) - key.Char(char) -> Ok(char_control(char)) + key.Char(char) -> { + case char { + " " -> Ok(TogglePlayPause) + "/" -> Ok(Search(input: "", capturing: True)) + "q" -> Ok(Exit) + + // NOOP + _ -> Error(Nil) + } + } + + // NOOP _ -> Error(Nil) } } -fn char_control(char: String) -> Control { - case char { - " " -> TogglePlayPause - "/" -> Search - "q" -> Exit - _ -> Raw(char) +pub fn searching_from_key(key: Key, input: String) -> Result(Control, Nil) { + case key { + key.Char(char) -> Ok(Search(input <> char, True)) + key.Backspace -> Ok(Search(string.drop_end(input, 1), True)) + key.Return -> Ok(Search(input, False)) + + // NOOP + _ -> Error(Nil) } } diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 525c0be..31d006c 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -1,9 +1,8 @@ import gleam/erlang/process.{type Name, type Pid, type Subject} import gleam/otp/actor -import gleam/result import gleam/string -import musicplayer/control.{type Control} +import musicplayer/control.{type Mode} import musicplayer/input/key.{type Key} import musicplayer/logging/logging import musicplayer/mpv/control as mpv_control @@ -11,19 +10,9 @@ import musicplayer/time/time import musicplayer/ui/control as ui_control import musicplayer/ui/layout -type Mode { - Idle - Searching -} - -type Input { - Input(capturing: Bool, content: String) -} - type State { State( mode: Mode, - input: Input, ui: Subject(ui_control.Control), mpv: Subject(mpv_control.Control), ) @@ -36,10 +25,8 @@ pub fn new( ) -> Result(Pid, String) { let input_keys = process.named_subject(input_keys_name) - let input = Input(False, "") - case - actor.new(State(Idle, input, ui, mpv)) + actor.new(State(control.Idle, ui, mpv)) |> actor.on_message(handle_message) |> actor.start { @@ -49,7 +36,7 @@ pub fn new( logging.log("musicplayer - started") process.spawn(fn() { let assert Ok(_) = process.register(process.self(), input_keys_name) - handle_key(musicplayer, input_keys) + forward_key(musicplayer, input_keys) }) process.spawn(fn() { update_playback_time_loop(mpv, ui, 250) }) @@ -59,88 +46,58 @@ pub fn new( } } -fn handle_message(state: State, control: Control) -> actor.Next(State, Control) { - case control { - control.Search -> { - logging.log("musicplayer - initiating search") +fn handle_message(state: State, key: Key) -> actor.Next(State, Key) { + case control.from_key(key, state.mode) { + Error(_) -> actor.continue(state) + Ok(c) -> + case c { + control.Search(input, capturing) -> { + case capturing { + True -> { + logging.log("musicplayer - searching: " <> input) - update_search(state.ui, "searching: ") + update_search(state.ui, "searching: " <> input) - actor.continue( - State( - ..state, - mode: Searching, - input: Input(..state.input, capturing: True), - ), - ) - } + actor.continue(State(..state, mode: control.Searching(input))) + } + False -> { + logging.log( + "musicplayer - recieved return. `input`: " + <> "'" + <> input + <> "'", + ) - control.Raw(content) -> { - logging.log("musicplayer - recieved raw input: " <> content) + update_search(state.ui, "") - let content = case state.mode { - Idle -> state.input.content - Searching -> { - let updated = state.input.content <> content - update_search(state.ui, "searching: " <> updated) - updated + actor.continue(State(..state, mode: control.Idle)) + } + } + } + control.TogglePlayPause -> { + logging.log("musicplayer - toggling play/pause") + + process.send(state.mpv, mpv_control.TogglePlayPause) + update_playback_time(state.mpv, state.ui) + actor.continue(state) + } + control.Exit -> { + logging.log("musicplayer - initiating musicplayer shutdown") + // Close `mpv` socket + process.call(state.mpv, 1000, fn(reply_to) { + mpv_control.Exit(reply_to) + }) + + // Reset terminal state (show cursor etc.) + process.call(state.ui, 1000, fn(reply_to) { + ui_control.Exit(reply_to) + }) + + logging.log("musicplayer - stopped") + + actor.stop() } } - - actor.continue(State(..state, input: Input(..state.input, content:))) - } - control.Backspace -> { - logging.log("musicplayer - recieved backspace") - - let content = case state.mode { - Idle -> state.input.content - Searching -> { - let updated = string.drop_end(state.input.content, 1) - update_search(state.ui, "searching: " <> updated) - updated - } - } - actor.continue(State(..state, input: Input(..state.input, content:))) - } - control.Return -> { - logging.log( - "musicplayer - recieved return. `input.capture`: " - <> "'" - <> state.input.content - <> "'", - ) - - // 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 -> { - logging.log("musicplayer - toggling play/pause") - - process.send(state.mpv, mpv_control.TogglePlayPause) - update_playback_time(state.mpv, state.ui) - actor.continue(state) - } - control.Exit -> { - logging.log("musicplayer - initiating musicplayer shutdown") - // Close `mpv` socket - process.call(state.mpv, 1000, fn(reply_to) { mpv_control.Exit(reply_to) }) - - // Reset terminal state (show cursor etc.) - process.call(state.ui, 1000, fn(reply_to) { ui_control.Exit(reply_to) }) - - logging.log("musicplayer - stopped") - - actor.stop() - } } } @@ -189,14 +146,14 @@ fn update_search(ui: Subject(ui_control.Control), content: String) -> Nil { process.send(ui, ui_control.UpdateState(layout.Search, content)) } -/// `handle_key` listens to a subject onto which `input` will send messages with `Key`s -fn handle_key(musicplayer: Subject(Control), input_keys: Subject(Key)) -> Nil { +/// `forward_key` listens to a subject onto which `input` will send messages with `Key`s +/// that is then forwarded to the `musicplayer` agent to handle +fn forward_key(musicplayer: Subject(Key), input_keys: Subject(Key)) -> Nil { let _ = process.new_selector() |> process.select(input_keys) |> process.selector_receive_forever - |> control.from_key - |> result.map(process.send(musicplayer, _)) + |> process.send(musicplayer, _) - handle_key(musicplayer, input_keys) + forward_key(musicplayer, input_keys) } diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index 34978eb..73d7698 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,11 +1,9 @@ import gleam/dict -import gleam/int import gleam/list import gleam/pair import gleam/set import gleam/string -import musicplayer/logging/logging import musicplayer/ui/internal import musicplayer/ui/plot.{type Buffer} @@ -125,13 +123,8 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { Layout(..layout, columns:, rows:) } -pub fn render(layout: Layout) -> Nil { - [layout.columns, layout.rows] - |> list.map(int.to_string) - |> string.join(" ") - |> string.append("layout - render: ", _) - |> logging.log +pub fn render(layout: Layout) -> Nil { let context = RenderContext( parent_width: layout.columns, diff --git a/src/musicplayer/ui/plot.gleam b/src/musicplayer/ui/plot.gleam index 91d1774..4868bf3 100644 --- a/src/musicplayer/ui/plot.gleam +++ b/src/musicplayer/ui/plot.gleam @@ -29,6 +29,7 @@ pub fn text(buffer: Buffer, text: String, x: Int, y: Int) -> Buffer { } pub fn box(buffer: Buffer, x: Int, y: Int, width: Int, height: Int) -> Buffer { + // TODO move box style to `layout.Style` let box_chars = #("┌", "┐", "└", "┘", "─", "│") let #(tl, tr, bl, br, hor, ver) = box_chars diff --git a/test/musicplayer/control_test.gleam b/test/musicplayer/control_test.gleam index ab75436..2c04887 100644 --- a/test/musicplayer/control_test.gleam +++ b/test/musicplayer/control_test.gleam @@ -1,7 +1,7 @@ import gleam/list import gleeunit -import musicplayer/control.{type Control} +import musicplayer/control.{type Control, type Mode} import musicplayer/input/key.{type Key, Char} pub fn main() -> Nil { @@ -9,16 +9,24 @@ pub fn main() -> Nil { } type TestCase { - TestCase(key: Key, expected: Result(Control, Nil)) + TestCase(key: Key, mode: Mode, expected: Result(Control, Nil)) } pub fn control_from_key_test() { - let test_cases = [ - TestCase(Char(" "), Ok(control.TogglePlayPause)), - TestCase(Char("q"), Ok(control.Exit)), + let idle_tests = [ + TestCase(Char(" "), control.Idle, Ok(control.TogglePlayPause)), + TestCase(Char("/"), control.Idle, Ok(control.Search("", True))), + TestCase(Char("q"), control.Idle, Ok(control.Exit)), ] - list.each(test_cases, fn(tc) { - assert tc.expected == control.from_key(tc.key) + let search_tests = [ + TestCase(Char("a"), control.Searching(""), Ok(control.Search("a", True))), + TestCase(Char("b"), control.Searching("a"), Ok(control.Search("ab", True))), + ] + + let test_cases = [idle_tests, search_tests] + + list.each(list.flatten(test_cases), fn(tc) { + assert tc.expected == control.from_key(tc.key, tc.mode) }) }