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 b1542a6..2f06b19 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,40 +1,74 @@ import gleam/dict +import gleam/float +import gleam/int import gleam/list +import gleam/pair +import gleam/set +import gleam/string +import gleam/string_tree +import musicplayer/logging/logging +import musicplayer/ui/ansi import musicplayer/ui/internal -pub type Layout { - Layout(width: Int, height: Int, nodes: dict.Dict(Section, Node)) -} +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( @@ -44,29 +78,153 @@ 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), - ), - ) - } -} - -pub fn update_dimensions(layout: Layout, width: Int, height: Int) -> Layout { - Layout(..layout, width:, height:) -} - -pub fn render(layout: Layout, from: Section) -> Nil { - case dict.get(layout.nodes, from) { - Error(_) -> Nil Ok(node) -> { - list.each(node.children, fn(child) { render(layout, child) }) + let updated = case node { + Cell(..) -> Cell(..node, content: content) + Row(..) -> Row(..node, content: content) + } - internal.print_at(node.content, node.x, node.y) + Layout(..layout, nodes: dict.insert(layout.nodes, section, updated)) + } + } +} + +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 container_width = int.to_float(layout.columns) + let container_height = int.to_float(layout.rows) + let container_top_left_x = 1 + let container_top_left_y = 1 + + let ansi_renders = + Renders( + box: fn(tree, x, y, w, h) { + string_tree.append(tree, ansi.box(x, y, w, h)) + }, + text: fn(tree, chars, x, y) { + string_tree.append(tree, ansi.text(chars, x, y)) + }, + ) + + string_tree.new() + |> render_generic( + layout, + container_width, + container_height, + container_top_left_x, + container_top_left_y, + 0, + Section(root_section), + _, + ansi_renders, + ) + |> string_tree.to_string + |> internal.print +} + +pub type Renders(into) { + Renders( + text: fn(into, String, Int, Int) -> into, + box: fn(into, Int, Int, Int, Int) -> into, + ) +} + +pub fn render_generic( + layout: Layout, + // Dimensions + container_width: Float, + container_height: Float, + container_tl_x: Int, + container_tl_y: Int, + // State + index: Int, + 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.0 + + let #(width, height) = case node.style.dimensions { + Percent(width:, height:) -> { + let width = + container_width *. { int.to_float(width) /. 100.0 } + |> float.floor + |> float.truncate + + let height = + container_height *. { int.to_float(height) /. 100.0 } + |> float.floor + |> float.truncate + + #(width, height) + } + } + + let #(cx, cy) = case node { + Row(..) -> #(container_tl_x, container_tl_y + { index * height }) + Cell(..) -> #(container_tl_x + { index * width }, container_tl_y) + } + + let parent = + render_into + |> renders.box(cx, cy, width, height) + |> renders.text(node.content, cx, cy) + + 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 #(width, height) = case node.style.dimensions { + Percent(width:, height:) -> { + let width = + container_width *. { int.to_float(width) /. 100.0 } -. margin + |> float.floor + + let height = + container_height + *. { int.to_float(height) /. 100.0 } + -. margin + |> float.floor + + #(width, height) + } + } + + let child_origin_x = container_tl_x + 1 + let child_origin_y = container_tl_y + 1 + + render_generic( + layout, + width, + height, + child_origin_x, + child_origin_y, + i, + 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 094f946..84910ec 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -17,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)) @@ -38,7 +65,7 @@ pub fn new() -> Result(Subject(Control), String) { internal.clear_screen() internal.hide_cursor() - redraw_on_update_loop(redraw) + redraw_loop(redraw) }) Ok(ui) @@ -51,33 +78,30 @@ 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) -> { @@ -88,13 +112,11 @@ fn handle_message( } } -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 - internal.clear_screen() - layout.render(layout, layout.Root) - - redraw_on_update_loop(redraw) + redraw_loop(redraw) } fn update_dimensions_on_interval(ui: Subject(Control), interval_ms: Int) { 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..88562c6 --- /dev/null +++ b/test/musicplayer/ui/virtual_ansi.gleam @@ -0,0 +1,113 @@ +import gleam/dict +import gleam/int +import gleam/list +import gleam/string + +import musicplayer/ui/layout.{type Layout, Renders} + +pub type Screen = + dict.Dict(#(Int, Int), String) + +pub fn render(layout: Layout) -> String { + let test_renders = + Renders( + box: fn(screen, x, y, w, h) { box(screen, x, y, w, h) }, + text: fn(screen, chars, x, y) { text(screen, chars, x, y) }, + ) + + let screen = + layout.render_generic( + layout, + int.to_float(layout.columns), + int.to_float(layout.rows), + 1, + 1, + 0, + 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) }) +}