From 26d9985a38f301f2440301645bc4588d0aecb24f Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 23 Nov 2025 10:11:21 +0100 Subject: [PATCH] Add `ui` module and ability to update its state --- src/musicplayer.gleam | 4 ++ src/musicplayer/input/input.gleam | 1 - src/musicplayer/musicplayer.gleam | 30 ++++++++++++--- src/musicplayer/ui/control.gleam | 7 ++++ src/musicplayer/ui/internal.gleam | 19 ++++++++++ src/musicplayer/ui/ui.gleam | 62 +++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/musicplayer/ui/control.gleam create mode 100644 src/musicplayer/ui/internal.gleam create mode 100644 src/musicplayer/ui/ui.gleam diff --git a/src/musicplayer.gleam b/src/musicplayer.gleam index 6d7bfc3..ee40bdf 100644 --- a/src/musicplayer.gleam +++ b/src/musicplayer.gleam @@ -4,6 +4,7 @@ import musicplayer/input/input import musicplayer/input/key.{type Key} import musicplayer/mpv/mpv import musicplayer/musicplayer +import musicplayer/ui/ui pub fn main() -> Nil { let input_keys_name: Name(Key) = process.new_name("input_keys") @@ -15,13 +16,16 @@ pub fn main() -> Nil { // inject input let input_inject_name: Name(Key) = process.new_name("input_inject_keys") + // TODO should input.new just return the inject_subject? input.new(input_keys_name, input_stream_name, input_inject_name) + let assert Ok(ui) = ui.new() let assert Ok(mpv) = mpv.new() let exit = process.new_subject() let assert Ok(_) = musicplayer.new( + ui, mpv, input_keys_name, input_stream_name, diff --git a/src/musicplayer/input/input.gleam b/src/musicplayer/input/input.gleam index f590055..f3c9beb 100644 --- a/src/musicplayer/input/input.gleam +++ b/src/musicplayer/input/input.gleam @@ -26,7 +26,6 @@ pub fn new( read_input(input_keys, input_stream, input_inject) }) - echo "waiting for input" key.start_raw_shell() Nil } diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 0248bcd..6abcc34 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -7,9 +7,11 @@ import gleam/string import musicplayer/control.{type Control} import musicplayer/input/key.{type Key} import musicplayer/mpv/control as mpv_control +import musicplayer/ui/control as ui_control -type State(mpv, input_inject, exit) { +type State(ui, mpv, input_inject, exit) { State( + ui: Subject(ui_control.Control), mpv: Subject(mpv_control.Control), input_inject: Subject(Key), exit: Subject(Nil), @@ -17,6 +19,7 @@ type State(mpv, input_inject, exit) { } pub fn new( + ui: Subject(ui_control.Control), mpv: Subject(mpv_control.Control), input_keys_name: Name(Key), input_stream_name: Name(List(String)), @@ -28,7 +31,7 @@ pub fn new( let input_inject = process.named_subject(input_inject_name) case - actor.new(State(mpv, input_inject, exit)) + actor.new(State(ui, mpv, input_inject, exit)) |> actor.on_message(handle_message) |> actor.start { @@ -51,9 +54,9 @@ pub fn new( } fn handle_message( - state: State(mpv, input_inject, exit), + state: State(ui, mpv, input_inject, exit), control: Control, -) -> actor.Next(State(mpv, input_inject, exit), Control) { +) -> actor.Next(State(ui, mpv, input_inject, exit), Control) { case control { control.Search -> { process.send(state.input_inject, key.Continue([key.input_introducer])) @@ -70,9 +73,21 @@ fn handle_message( mpv_control.GetPlaybackTime(reply_to) }) { - Error(err) -> echo "! could not get playbackTime: " <> err.details + Error(err) -> + process.send( + state.ui, + ui_control.UpdateState( + "playback time: N/A (err: " <> err.details <> ")", + ), + ) + Ok(mpv_control.PlaybackTime(data: playback_time)) -> - echo "playbacktime from mpv: " <> float.to_string(playback_time) + process.send( + state.ui, + ui_control.UpdateState( + "playback time: " <> float.to_string(playback_time), + ), + ) } actor.continue(state) @@ -81,6 +96,9 @@ fn handle_message( // 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) }) + // End main process process.send(state.exit, Nil) actor.stop() diff --git a/src/musicplayer/ui/control.gleam b/src/musicplayer/ui/control.gleam new file mode 100644 index 0000000..d8fc892 --- /dev/null +++ b/src/musicplayer/ui/control.gleam @@ -0,0 +1,7 @@ +import gleam/erlang/process.{type Subject} + +pub type Control { + UpdateState(content: String) + + Exit(reply_to: Subject(Nil)) +} diff --git a/src/musicplayer/ui/internal.gleam b/src/musicplayer/ui/internal.gleam new file mode 100644 index 0000000..c73db52 --- /dev/null +++ b/src/musicplayer/ui/internal.gleam @@ -0,0 +1,19 @@ +import gleam/io + +pub fn print(content: String) -> Nil { + io.print(content <> "\n") +} + +// https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands + +pub fn clear_screen() -> Nil { + io.print("\u{001B}[2J\u{001B}[H") +} + +pub fn hide_cursor() -> Nil { + io.print("\u{001B}[?25l") +} + +pub fn show_cursor() -> Nil { + io.print("\u{001B}[?25h") +} diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam new file mode 100644 index 0000000..3496926 --- /dev/null +++ b/src/musicplayer/ui/ui.gleam @@ -0,0 +1,62 @@ +import gleam/erlang/process.{type Subject} +import gleam/otp/actor +import gleam/string + +import musicplayer/ui/control.{type Control} +import musicplayer/ui/internal as ui_internal + +pub type State(redraw, content) { + State(redraw: Subject(String), content: String) +} + +pub fn new() -> Result(Subject(Control), String) { + let redraw_name = process.new_name("redraw") + let redraw: Subject(String) = process.named_subject(redraw_name) + case + actor.new(State(redraw, "")) + |> actor.on_message(handle_message) + |> actor.start + { + Error(start_error) -> + Error("Could not start actor: " <> string.inspect(start_error)) + Ok(actor.Started(data: ui, ..)) -> { + process.spawn(fn() { + let assert Ok(_) = process.register(process.self(), redraw_name) + + ui_internal.clear_screen() + ui_internal.hide_cursor() + + redraw_loop(redraw) + }) + + Ok(ui) + } + } +} + +fn handle_message( + state: State(redraw, content), + control: Control, +) -> actor.Next(State(redraw, content), Control) { + case control { + control.UpdateState(content) -> { + let state = State(..state, content:) + actor.send(state.redraw, content) + actor.continue(state) + } + control.Exit(reply_to) -> { + ui_internal.show_cursor() + process.send(reply_to, Nil) + actor.stop() + } + } +} + +fn redraw_loop(redraw: Subject(String)) -> Nil { + let content = process.receive_forever(redraw) + + ui_internal.clear_screen() + ui_internal.print(content) + + redraw_loop(redraw) +}