From 2e811796be84aac84173fad82657178a65cca2dc Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 23 Nov 2025 13:21:01 +0100 Subject: [PATCH] wip-layout --- src/musicplayer/musicplayer.gleam | 69 ++++++++++++++++++++----------- src/musicplayer/time/time.gleam | 20 +++++++++ src/musicplayer/ui/control.gleam | 5 ++- src/musicplayer/ui/internal.gleam | 6 +++ src/musicplayer/ui/layout.gleam | 47 +++++++++++++++++++++ src/musicplayer/ui/ui.gleam | 58 ++++++++++++++++++++------ 6 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 src/musicplayer/time/time.gleam create mode 100644 src/musicplayer/ui/layout.gleam diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 6abcc34..438d1e4 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -1,5 +1,4 @@ import gleam/erlang/process.{type Name, type Subject} -import gleam/float import gleam/otp/actor import gleam/result import gleam/string @@ -7,7 +6,9 @@ import gleam/string import musicplayer/control.{type Control} import musicplayer/input/key.{type Key} import musicplayer/mpv/control as mpv_control +import musicplayer/time/time import musicplayer/ui/control as ui_control +import musicplayer/ui/layout type State(ui, mpv, input_inject, exit) { State( @@ -43,6 +44,8 @@ pub fn new( handle_key(musicplayer, input_keys) }) + process.spawn(fn() { update_playback_time_loop(mpv, ui, 1000) }) + process.spawn(fn() { let assert Ok(_) = process.register(process.self(), input_stream_name) temp_input_stream(input_stream) @@ -67,29 +70,7 @@ fn handle_message( echo "toggling play/pause" process.send(state.mpv, mpv_control.TogglePlayPause) - - case - process.call(state.mpv, 1000, fn(reply_to) { - mpv_control.GetPlaybackTime(reply_to) - }) - { - Error(err) -> - process.send( - state.ui, - ui_control.UpdateState( - "playback time: N/A (err: " <> err.details <> ")", - ), - ) - - Ok(mpv_control.PlaybackTime(data: playback_time)) -> - process.send( - state.ui, - ui_control.UpdateState( - "playback time: " <> float.to_string(playback_time), - ), - ) - } - + update_playback_time(state.mpv, state.ui) actor.continue(state) } control.Exit -> { @@ -106,6 +87,46 @@ fn handle_message( } } +fn update_playback_time_loop( + mpv: Subject(mpv_control.Control), + ui: Subject(ui_control.Control), + interval_ms: Int, +) { + process.sleep(interval_ms) + update_playback_time(mpv, ui) + + update_playback_time_loop(mpv, ui, interval_ms) +} + +fn update_playback_time( + mpv: Subject(mpv_control.Control), + ui: Subject(ui_control.Control), +) -> Nil { + case + process.call(mpv, 1000, fn(reply_to) { + mpv_control.GetPlaybackTime(reply_to) + }) + { + Error(err) -> + process.send( + ui, + ui_control.UpdateState( + layout.PlaybackTime, + "playback time: N/A (err: " <> err.details <> ")", + ), + ) + + Ok(mpv_control.PlaybackTime(data: playback_time)) -> + process.send( + ui, + ui_control.UpdateState( + layout.PlaybackTime, + "playback time: " <> time.to_time_string(playback_time), + ), + ) + } +} + /// `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(musicplayer: Subject(Control), input_keys: Subject(Key)) -> Nil { diff --git a/src/musicplayer/time/time.gleam b/src/musicplayer/time/time.gleam new file mode 100644 index 0000000..8bcf57b --- /dev/null +++ b/src/musicplayer/time/time.gleam @@ -0,0 +1,20 @@ +import gleam/float +import gleam/int +import gleam/string + +pub fn to_time_string(seconds: Float) -> String { + let total = float.truncate(seconds) + + let minutes = total / 60 + let seconds = total % 60 + + let mins = + int.to_string(minutes) + |> string.pad_start(to: 2, with: "0") + + let secs = + int.to_string(seconds) + |> string.pad_start(to: 2, with: "0") + + mins <> ":" <> secs +} diff --git a/src/musicplayer/ui/control.gleam b/src/musicplayer/ui/control.gleam index d8fc892..c98d4e9 100644 --- a/src/musicplayer/ui/control.gleam +++ b/src/musicplayer/ui/control.gleam @@ -1,7 +1,10 @@ import gleam/erlang/process.{type Subject} +import musicplayer/ui/layout.{type Section} + pub type Control { - UpdateState(content: String) + Redraw + UpdateState(section: Section, content: String) Exit(reply_to: Subject(Nil)) } diff --git a/src/musicplayer/ui/internal.gleam b/src/musicplayer/ui/internal.gleam index c73db52..424f68e 100644 --- a/src/musicplayer/ui/internal.gleam +++ b/src/musicplayer/ui/internal.gleam @@ -1,3 +1,4 @@ +import gleam/int import gleam/io pub fn print(content: String) -> Nil { @@ -10,6 +11,11 @@ pub fn clear_screen() -> Nil { io.print("\u{001B}[2J\u{001B}[H") } +pub fn print_at(text: String, x: Int, y: Int) -> Nil { + let seq = "\u{001B}[" <> int.to_string(y) <> ";" <> int.to_string(x) <> "H" + io.print(seq <> text) +} + pub fn hide_cursor() -> Nil { io.print("\u{001B}[?25l") } diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam new file mode 100644 index 0000000..db937b1 --- /dev/null +++ b/src/musicplayer/ui/layout.gleam @@ -0,0 +1,47 @@ +import gleam/dict + +pub type Layout { + Layout(nodes: dict.Dict(Section, Node)) +} + +pub type Section { + Root + Header + PlaybackTime +} + +pub type Node { + Node(content: String, x: Int, y: Int, children: List(Section)) +} + +pub fn new() -> Layout { + let nodes = + dict.from_list([ + #( + Root, + Node(content: "Music Player", x: 1, y: 1, children: [ + Header, + PlaybackTime, + ]), + ), + #(PlaybackTime, Node(content: "00:00", x: 1, y: 2, children: [])), + ]) + + Layout(nodes: nodes) +} + +pub fn update_section( + layout: Layout, + section: Section, + content: String, +) -> Layout { + case dict.get(layout.nodes, section) { + Error(_) -> layout + Ok(node) -> + Layout(nodes: dict.insert( + layout.nodes, + section, + Node(..node, content: content), + )) + } +} diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam index 3496926..0ca1ec2 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -1,19 +1,25 @@ +import gleam/dict import gleam/erlang/process.{type Subject} +import gleam/list import gleam/otp/actor import gleam/string import musicplayer/ui/control.{type Control} import musicplayer/ui/internal as ui_internal +import musicplayer/ui/layout.{type Layout, type Section} pub type State(redraw, content) { - State(redraw: Subject(String), content: String) + State(redraw: Subject(Layout), layout: Layout) } pub fn new() -> Result(Subject(Control), String) { let redraw_name = process.new_name("redraw") - let redraw: Subject(String) = process.named_subject(redraw_name) + let redraw: Subject(Layout) = process.named_subject(redraw_name) + + let layout = layout.new() + case - actor.new(State(redraw, "")) + actor.new(State(redraw, layout)) |> actor.on_message(handle_message) |> actor.start { @@ -26,24 +32,33 @@ pub fn new() -> Result(Subject(Control), String) { ui_internal.clear_screen() ui_internal.hide_cursor() - redraw_loop(redraw) + redraw_on_update_loop(redraw) }) + process.spawn(fn() { redraw_on_tick_loop(ui, 1000) }) + Ok(ui) } } } fn handle_message( - state: State(redraw, content), + state: State(redraw, layout), control: Control, -) -> actor.Next(State(redraw, content), Control) { +) -> actor.Next(State(redraw, layout), Control) { case control { - control.UpdateState(content) -> { - let state = State(..state, content:) - actor.send(state.redraw, content) + control.Redraw -> { + actor.send(state.redraw, state.layout) actor.continue(state) } + control.UpdateState(section, content) -> { + let layout = layout.update_section(state.layout, section, content) + let state = State(..state, layout:) + + actor.send(state.redraw, layout) + actor.continue(state) + } + control.Exit(reply_to) -> { ui_internal.show_cursor() process.send(reply_to, Nil) @@ -52,11 +67,28 @@ fn handle_message( } } -fn redraw_loop(redraw: Subject(String)) -> Nil { - let content = process.receive_forever(redraw) +fn redraw_on_update_loop(redraw: Subject(Layout)) -> Nil { + let layout = process.receive_forever(redraw) ui_internal.clear_screen() - ui_internal.print(content) + render_layout(layout, layout.Root) - redraw_loop(redraw) + redraw_on_update_loop(redraw) +} + +fn redraw_on_tick_loop(ui: Subject(Control), interval_ms: Int) { + process.sleep(interval_ms) + process.send(ui, control.Redraw) + + redraw_on_tick_loop(ui, interval_ms) +} + +fn render_layout(layout: Layout, from: Section) -> Nil { + case dict.get(layout.nodes, from) { + Error(_) -> Nil + Ok(node) -> { + list.each(node.children, fn(child) { render_layout(layout, child) }) + ui_internal.print_at(node.content, node.x, node.y) + } + } }