diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 266b45b..efa4774 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -146,6 +146,7 @@ fn update_playback_time_loop( interval_ms: Int, ) { process.sleep(interval_ms) + // TODO only update if state is playing update_playback_time(mpv, ui) update_playback_time_loop(mpv, ui, interval_ms) diff --git a/src/musicplayer/ui/ansi.gleam b/src/musicplayer/ui/ansi.gleam new file mode 100644 index 0000000..4acabf7 --- /dev/null +++ b/src/musicplayer/ui/ansi.gleam @@ -0,0 +1,42 @@ +import gleam/list +import gleam/string +import gleam/string_tree + +import musicplayer/ui/internal + +pub fn text(chars: String, x: Int, y: Int) { + internal.chars_at(chars, x, y) +} + +pub fn box(x: Int, y: Int, width: Int, height: Int) -> String { + let box_chars = #("┌", "┐", "└", "┘", "─", "│") + let #(tl, tr, bl, br, h, v) = box_chars + + // Add top of box + let tree = + string_tree.new() + |> string_tree.append(internal.chars_at( + tl <> string.repeat(h, width - 2) <> tr, + x, + y, + )) + + // Add sides of box + let tree_with_sides = + list.range(1, height - 2) + |> list.map(fn(row) { + tree + |> string_tree.append(internal.chars_at(v, x, y + row)) + |> string_tree.append(internal.chars_at(v, x + width - 1, y + row)) + }) + |> string_tree.concat + + // Add bottom of box + tree_with_sides + |> string_tree.append(internal.chars_at( + bl <> string.repeat(h, width - 2) <> br, + x, + y + height - 1, + )) + |> string_tree.to_string +} diff --git a/src/musicplayer/ui/control.gleam b/src/musicplayer/ui/control.gleam index b86282e..bb9a501 100644 --- a/src/musicplayer/ui/control.gleam +++ b/src/musicplayer/ui/control.gleam @@ -3,7 +3,7 @@ import gleam/erlang/process.{type Subject} import musicplayer/ui/layout.{type Section} pub type Control { - UpdateDimensions(width: Int, height: Int) + UpdateDimensions(columns: Int, rows: Int) 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 9336fd1..2e8a8af 100644 --- a/src/musicplayer/ui/internal.gleam +++ b/src/musicplayer/ui/internal.gleam @@ -7,9 +7,13 @@ pub fn clear_screen() -> Nil { io.print("\u{001B}[2J\u{001B}[H") } -pub fn print_at(text: String, x: Int, y: Int) -> Nil { +pub fn chars_at(chars: String, x: Int, y: Int) -> String { let seq = "\u{001B}[" <> int.to_string(y) <> ";" <> int.to_string(x) <> "H" - io.print(seq <> text) + seq <> chars +} + +pub fn print(chars: String) -> Nil { + io.print(chars) } pub fn hide_cursor() -> Nil { diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index c8af609..457983e 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,37 +1,73 @@ import gleam/dict +import gleam/int +import gleam/list +import gleam/pair +import gleam/set +import gleam/string +import gleam/string_tree -pub type Layout { - Layout(width: Int, height: Int, nodes: dict.Dict(Section, Node)) -} +import musicplayer/logging/logging +import musicplayer/ui/ansi +import musicplayer/ui/internal + +pub const root_section = "reserved_root_section" pub type Section { - Root + Section(String) + Header Search PlaybackTime } -pub type Node { - Node(content: String, x: Int, y: Int, children: List(Section)) +pub type Dimension { + Percent(width: Int, height: Int) + // TODO add Flex that flows } -pub fn new() -> Layout { - let nodes = - dict.from_list([ - #( - Root, - Node(content: "", x: 0, y: 0, children: [ - Header, - Search, - PlaybackTime, - ]), - ), - #(Header, Node(content: "Music Player", x: 1, y: 1, children: [])), - #(Search, Node(content: "", x: 30, y: 1, children: [])), - #(PlaybackTime, Node(content: "00:00", x: 1, y: 2, children: [])), - ]) +pub type Style { + Style(dimensions: Dimension) +} - Layout(0, 0, nodes: nodes) +pub type Node { + Row(content: String, style: Style, children: List(Section)) + Cell(content: String, style: Style) +} + +pub type Layout { + Layout(columns: Int, rows: Int, nodes: dict.Dict(Section, Node)) +} + +pub fn new(columns: Int, rows: Int, nodes: List(#(Section, Node))) -> Layout { + let children = + nodes + |> list.flat_map(fn(node) { + case pair.second(node) { + Row(children: c, ..) -> c + Cell(..) -> [] + } + }) + |> set.from_list + + // All sections that are not children of other nodes will be added as + // children to the root + let orphans = + nodes + |> list.map(pair.first) + |> list.filter(fn(node) { !set.contains(children, node) }) + + let nodes = + dict.from_list(nodes) + |> dict.insert( + Section(root_section), + Row( + content: "", + style: Style(dimensions: Percent(width: 100, height: 100)), + children: orphans, + ), + ) + + Layout(columns:, rows:, nodes:) } pub fn update_section( @@ -41,18 +77,148 @@ pub fn update_section( ) -> Layout { case dict.get(layout.nodes, section) { Error(_) -> layout - Ok(node) -> - Layout( - ..layout, - nodes: dict.insert( - layout.nodes, - section, - Node(..node, content: content), - ), - ) + Ok(node) -> { + let updated = case node { + Cell(..) -> Cell(..node, content: content) + Row(..) -> Row(..node, content: content) + } + + Layout(..layout, nodes: dict.insert(layout.nodes, section, updated)) + } } } -pub fn update_dimensions(layout: Layout, width: Int, height: Int) -> Layout { - Layout(..layout, width:, height:) +pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { + Layout(..layout, columns:, rows:) +} + +pub fn render(layout: Layout) -> Nil { + internal.clear_screen() + + [layout.columns, layout.rows] + |> list.map(int.to_string) + |> string.join(" ") + |> string.append("layout - render: ", _) + |> logging.log + + let context = + RenderContext( + parent_width: layout.columns, + parent_height: layout.rows, + parent_top_left_x: 1, + parent_top_left_y: 1, + position_index: 0, + ) + + let ansi_renders = + Renders( + text: fn(tree, chars, x, y) { + string_tree.append(tree, ansi.text(chars, x, y)) + }, + box: fn(tree, x, y, w, h) { + string_tree.append(tree, ansi.box(x, y, w, h)) + }, + ) + + string_tree.new() + |> render_generic(layout, context, Section(root_section), _, ansi_renders) + |> string_tree.to_string + |> internal.print +} + +pub type Renders(into) { + Renders( + // into, chars, x, y + text: fn(into, String, Int, Int) -> into, + // into, x, y, width, height + box: fn(into, Int, Int, Int, Int) -> into, + ) +} + +pub type RenderContext { + RenderContext( + parent_width: Int, + parent_height: Int, + parent_top_left_x: Int, + parent_top_left_y: Int, + position_index: Int, + ) +} + +pub fn render_generic( + layout: Layout, + context: RenderContext, + from: Section, + render_into: into, + renders: Renders(into), +) -> into { + case dict.get(layout.nodes, from) { + Error(_) -> render_into + Ok(node) -> { + // Margin between container and the node being rendere + let margin = 2 + + let #(node_width, node_height) = case node.style.dimensions { + Percent(width:, height:) -> { + let width = { context.parent_width * width } / 100 + let height = { context.parent_height * height } / 100 + + #(width, height) + } + } + + // Check if this node should be placed to the left or below the parent + let #(node_top_left_x, node_top_left_y) = case node { + Row(..) -> #( + context.parent_top_left_x, + context.parent_top_left_y + { context.position_index * node_height }, + ) + Cell(..) -> #( + context.parent_top_left_x + { context.position_index * node_width }, + context.parent_top_left_y, + ) + } + + let parent = + render_into + |> renders.box( + node_top_left_x, + node_top_left_y, + node_width, + node_height, + ) + |> renders.text(node.content, node_top_left_x, node_top_left_y) + + case node { + Cell(..) -> parent + Row(children:, ..) -> { + list.index_map(children, fn(child, i) { #(i, child) }) + |> list.fold(parent, fn(acc_into, ic) { + let #(i, child) = ic + + let #(child_width, child_height) = case node.style.dimensions { + Percent(width:, height:) -> { + let width = { { context.parent_width * width } / 100 } - margin + let height = + { { context.parent_height * height } / 100 } - margin + + #(width, height) + } + } + + let context = + RenderContext( + parent_width: child_width, + parent_height: child_height, + parent_top_left_x: context.parent_top_left_x + 1, + parent_top_left_y: context.parent_top_left_y + 1, + position_index: i, + ) + + render_generic(layout, context, child, acc_into, renders) + }) + } + } + } + } } diff --git a/src/musicplayer/ui/layout_examples/layout_examples.gleam b/src/musicplayer/ui/layout_examples/layout_examples.gleam new file mode 100644 index 0000000..2c1b39c --- /dev/null +++ b/src/musicplayer/ui/layout_examples/layout_examples.gleam @@ -0,0 +1,56 @@ +import musicplayer/ui/internal +import musicplayer/ui/layout.{Percent, Section, Style} +import musicplayer/ui/layout_examples/wait_for_input.{wait_for_input} + +pub fn main() { + let assert Ok(columns) = internal.io_get_columns() + let assert Ok(rows) = internal.io_get_rows() + + two_rows_with_cells(columns, rows) + |> layout.render + + wait_for_input() +} + +/// Two rows: +/// First row has two cells +/// Second row has no cells +fn two_rows_with_cells(columns: Int, rows: Int) -> layout.Layout { + let nodes = [ + #( + Section("Row1"), + layout.Row( + content: "row 1", + style: Style(dimensions: Percent(width: 100, height: 50)), + children: [ + Section("A"), + Section("B"), + ], + ), + ), + #( + Section("A"), + layout.Cell( + content: "cell 1", + style: Style(dimensions: Percent(width: 50, height: 50)), + ), + ), + #( + Section("B"), + layout.Cell( + content: "cell 2", + style: Style(dimensions: Percent(width: 50, height: 50)), + ), + ), + #( + Section("Row2"), + layout.Row( + content: "row 2", + style: Style(dimensions: Percent(width: 50, height: 50)), + children: [], + ), + ), + ] + + layout.new(columns, rows, nodes) +} diff --git a/src/musicplayer/ui/layout_examples/wait_for_input.gleam b/src/musicplayer/ui/layout_examples/wait_for_input.gleam new file mode 100644 index 0000000..f2116e8 --- /dev/null +++ b/src/musicplayer/ui/layout_examples/wait_for_input.gleam @@ -0,0 +1,15 @@ +import gleam/erlang/process.{type Name} + +import musicplayer/input/input +import musicplayer/input/key.{type Key} + +pub fn wait_for_input() { + let input_keys_name: Name(Key) = process.new_name("input_keys") + let assert Ok(_) = process.register(process.self(), input_keys_name) + + input.new(input_keys_name) + + process.new_selector() + |> process.select(process.named_subject(input_keys_name)) + |> process.selector_receive_forever +} diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam index 80765c3..84910ec 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -1,4 +1,3 @@ -import gleam/dict import gleam/erlang/process.{type Subject} import gleam/int import gleam/list @@ -7,8 +6,8 @@ import gleam/string import musicplayer/logging/logging import musicplayer/ui/control.{type Control} -import musicplayer/ui/internal as ui_internal -import musicplayer/ui/layout.{type Layout, type Section} +import musicplayer/ui/internal +import musicplayer/ui/layout.{type Layout} pub type State(redraw, content) { State(redraw: Subject(Layout), layout: Layout) @@ -18,7 +17,34 @@ pub fn new() -> Result(Subject(Control), String) { let redraw_name = process.new_name("redraw") let redraw: Subject(Layout) = process.named_subject(redraw_name) - let layout = layout.new() + let layout = + [ + #( + layout.Header, + layout.Row( + content: "Foo (1) | Bar (2) | Baz (3)", + style: layout.Style(dimensions: layout.Percent(width: 100, height: 33)), + children: [], + ), + ), + #( + layout.Search, + layout.Row( + content: "", + style: layout.Style(dimensions: layout.Percent(width: 100, height: 33)), + children: [], + ), + ), + #( + layout.PlaybackTime, + layout.Row( + content: "00:00", + style: layout.Style(dimensions: layout.Percent(width: 100, height: 33)), + children: [], + ), + ), + ] + |> layout.new(0, 0, _) case actor.new(State(redraw, layout)) @@ -36,10 +62,10 @@ pub fn new() -> Result(Subject(Control), String) { process.spawn(fn() { let assert Ok(_) = process.register(process.self(), redraw_name) - ui_internal.clear_screen() - ui_internal.hide_cursor() + internal.clear_screen() + internal.hide_cursor() - redraw_on_update_loop(redraw) + redraw_loop(redraw) }) Ok(ui) @@ -52,54 +78,49 @@ fn handle_message( control: Control, ) -> actor.Next(State(redraw, layout), Control) { case control { - control.UpdateDimensions(width, height) -> { - let current_dimensions = #(state.layout.width, state.layout.height) + control.UpdateDimensions(columns, rows) -> { + let current_dimensions = #(state.layout.columns, state.layout.rows) - case #(width, height) == current_dimensions { + case #(columns, rows) == current_dimensions { True -> actor.continue(state) False -> { - [width, height] + [columns, rows] |> list.map(int.to_string) |> string.join(" ") |> string.append("ui - updating dimensions: ", _) |> logging.log - actor.continue( - State( - ..state, - layout: layout.update_dimensions(state.layout, width, height), - ), - ) + let layout = layout.update_dimensions(state.layout, columns, rows) + + process.send(state.redraw, layout) + actor.continue(State(..state, layout:)) } } } 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) + actor.continue(State(..state, layout:)) } control.Exit(reply_to) -> { - ui_internal.show_cursor() + internal.show_cursor() process.send(reply_to, Nil) actor.stop() } } } -fn redraw_on_update_loop(redraw: Subject(Layout)) -> Nil { - let layout = process.receive_forever(redraw) +fn redraw_loop(redraw: Subject(Layout)) -> Nil { + process.receive_forever(redraw) + |> layout.render - ui_internal.clear_screen() - render_layout(layout, layout.Root) - - redraw_on_update_loop(redraw) + redraw_loop(redraw) } fn update_dimensions_on_interval(ui: Subject(Control), interval_ms: Int) { - case ui_internal.io_get_columns(), ui_internal.io_get_rows() { + case internal.io_get_columns(), internal.io_get_rows() { Ok(width), Ok(height) -> { process.send(ui, control.UpdateDimensions(width, height)) } @@ -109,13 +130,3 @@ fn update_dimensions_on_interval(ui: Subject(Control), interval_ms: Int) { process.sleep(interval_ms) update_dimensions_on_interval(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) - } - } -} diff --git a/test/musicplayer/mpv/mpv_test.gleam b/test/musicplayer/mpv/mpv_test.gleam index e0fb0c2..65f9722 100644 --- a/test/musicplayer/mpv/mpv_test.gleam +++ b/test/musicplayer/mpv/mpv_test.gleam @@ -1,6 +1,6 @@ import gleeunit -import musicplayer/mpv/internal as control_internal +import musicplayer/mpv/internal pub fn main() -> Nil { gleeunit.main() @@ -10,6 +10,6 @@ pub fn parse_playback_time_test() { let json_string = "{\"data\":\"123.456789\",\"request_id\":0,\"error\":\"success\"}\n" - let assert Ok(data) = control_internal.parse_playback_time(json_string) + let assert Ok(data) = internal.parse_playback_time(json_string) assert data == 123.456789 } diff --git a/test/musicplayer/ui/layout_test.gleam b/test/musicplayer/ui/layout_test.gleam new file mode 100644 index 0000000..882871e --- /dev/null +++ b/test/musicplayer/ui/layout_test.gleam @@ -0,0 +1,92 @@ +import gleam/io +import gleam/string +import gleeunit +import gleeunit/should +import musicplayer/ui/virtual_ansi + +import musicplayer/ui/layout.{Percent, Section, Style} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn percent_layout_test() { + let nodes = [ + #( + Section("Row1"), + layout.Row( + content: "row 1", + style: Style(dimensions: Percent(width: 100, height: 50)), + children: [ + Section("A"), + Section("B"), + ], + ), + ), + #( + Section("A"), + layout.Cell( + content: "cell 1", + style: Style(dimensions: Percent(width: 50, height: 100)), + ), + ), + #( + Section("B"), + layout.Cell( + content: "cell 2", + style: Style(dimensions: Percent(width: 50, height: 100)), + ), + ), + #( + Section("Row2"), + layout.Row( + content: "row 1", + style: Style(dimensions: Percent(width: 100, height: 50)), + children: [], + ), + ), + ] + + let columns = 80 + let rows = 20 + let layout = layout.new(columns, rows, nodes) + + let expected = + " +┌──────────────────────────────────────────────────────────────────────────────┐ +│row 1────────────────────────────────────────────────────────────────────────┐│ +││cell 1───────────────────────────────┐cell 2───────────────────────────────┐││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +││└────────────────────────────────────┘└────────────────────────────────────┘││ +│└────────────────────────────────────────────────────────────────────────────┘│ +│row 1────────────────────────────────────────────────────────────────────────┐│ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│└────────────────────────────────────────────────────────────────────────────┘│ +└──────────────────────────────────────────────────────────────────────────────┘ +" + + let visual = virtual_ansi.render(layout) + case visual == string.trim(expected) { + True -> Nil + False -> { + io.println("Test failed") + io.println("Expected:") + io.println(string.trim(expected)) + + io.println("Got:") + io.println(visual) + + should.equal(visual, expected) + } + } +} diff --git a/test/musicplayer/ui/virtual_ansi.gleam b/test/musicplayer/ui/virtual_ansi.gleam new file mode 100644 index 0000000..5a1bf9a --- /dev/null +++ b/test/musicplayer/ui/virtual_ansi.gleam @@ -0,0 +1,118 @@ +import gleam/dict +import gleam/int +import gleam/list +import gleam/string + +import musicplayer/ui/layout.{type Layout, RenderContext, Renders} + +pub type Screen = + dict.Dict(#(Int, Int), String) + +pub fn render(layout: Layout) -> String { + let test_renders = + Renders( + text: fn(screen, chars, x, y) { text(screen, chars, x, y) }, + box: fn(screen, x, y, w, h) { box(screen, x, y, w, h) }, + ) + + let context = + RenderContext( + parent_width: layout.columns, + parent_height: layout.rows, + parent_top_left_x: 1, + parent_top_left_y: 1, + position_index: 0, + ) + + let screen = + layout.render_generic( + layout, + context, + layout.Section(layout.root_section), + dict.new(), + test_renders, + ) + + screen_to_string(screen) +} + +pub fn screen_to_string(screen: Screen) -> String { + let keys = dict.keys(screen) + + // Find the bounding box of the drawing + let max_x = list.fold(keys, 0, fn(m, k) { int.max(m, k.0) }) + let max_y = list.fold(keys, 0, fn(m, k) { int.max(m, k.1) }) + + // We start from 1 because ANSI is 1-based + let min_y = list.fold(keys, 1000, fn(m, k) { int.min(m, k.1) }) + + list.range(min_y, max_y) + |> list.map(fn(y) { + list.range(1, max_x) + |> list.map(fn(x) { + case dict.get(screen, #(x, y)) { + Ok(char) -> char + Error(_) -> " " + // Fill gaps with space + } + }) + |> string.join("") + }) + |> string.join("\n") +} + +pub fn text(screen: Screen, text: String, start_x: Int, y: Int) -> Screen { + // We use to_graphemes to ensure Unicode characters (like emoji or box lines) + // are treated as single visual units + text + |> string.to_graphemes + |> list.index_fold(screen, fn(acc, char, i) { + dict.insert(acc, #(start_x + i, y), char) + }) +} + +pub fn box(screen: Screen, x: Int, y: Int, w: Int, h: Int) -> Screen { + let box_chars = #("┌", "┐", "└", "┘", "─", "│") + let #(tl, tr, bl, br, hor, ver) = box_chars + + // Don't draw impossible boxes + case w < 2 || h < 2 { + True -> screen + False -> { + screen + // 1. Corners + |> dict.insert(#(x, y), tl) + |> dict.insert(#(x + w - 1, y), tr) + |> dict.insert(#(x, y + h - 1), bl) + |> dict.insert(#(x + w - 1, y + h - 1), br) + // 2. Top and Bottom edges + |> horizontal_line(x + 1, y, w - 2, hor) + |> horizontal_line(x + 1, y + h - 1, w - 2, hor) + // 3. Side edges + |> vertical_line(x, y + 1, h - 2, ver) + |> vertical_line(x + w - 1, y + 1, h - 2, ver) + } + } +} + +fn horizontal_line( + screen: Screen, + x: Int, + y: Int, + len: Int, + char: String, +) -> Screen { + list.range(0, len - 1) + |> list.fold(screen, fn(acc, i) { dict.insert(acc, #(x + i, y), char) }) +} + +fn vertical_line( + screen: Screen, + x: Int, + y: Int, + len: Int, + char: String, +) -> Screen { + list.range(0, len - 1) + |> list.fold(screen, fn(acc, i) { dict.insert(acc, #(x, y + i), char) }) +}