From 15cda587605a473e0426d894b545e69405391859 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Tue, 2 Dec 2025 20:50:12 +0100 Subject: [PATCH] wip --- src/musicplayer/musicplayer.gleam | 1 + src/musicplayer/ui/ansi.gleam | 42 ++++ src/musicplayer/ui/internal.gleam | 8 +- src/musicplayer/ui/layout.gleam | 201 ++++++++++++++++-- .../ui/layout_examples/layout_examples.gleam | 82 +++++++ .../ui/layout_examples/wait_for_input.gleam | 15 ++ src/musicplayer/ui/ui.gleam | 25 +-- test/musicplayer/ui/layout_test.gleam | 118 ++++++++++ test/musicplayer/ui/virtual_ansi.gleam | 113 ++++++++++ 9 files changed, 567 insertions(+), 38 deletions(-) create mode 100644 src/musicplayer/ui/ansi.gleam create mode 100644 src/musicplayer/ui/layout_examples/layout_examples.gleam create mode 100644 src/musicplayer/ui/layout_examples/wait_for_input.gleam create mode 100644 test/musicplayer/ui/layout_test.gleam create mode 100644 test/musicplayer/ui/virtual_ansi.gleam 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/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..7be89d4 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,21 +1,42 @@ import gleam/dict +import gleam/float +import gleam/int import gleam/list +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 type Section { + Section(String) + Root Header Search PlaybackTime } +pub type NodeType { + Container + Row + Cell +} + +/// A Nodes width and height is in percentage (of the available width/height of its parent Node) pub type Node { - Node(content: String, x: Int, y: Int, children: List(Section)) + Node( + t: NodeType, + content: String, + width_percent: Int, + height_percent: Int, + children: List(Section), + ) +} + +pub type Layout { + Layout(width: Int, height: Int, nodes: dict.Dict(Section, Node)) } pub fn new() -> Layout { @@ -23,18 +44,46 @@ pub fn new() -> Layout { dict.from_list([ #( Root, - Node(content: "", x: 0, y: 0, children: [ - Header, - Search, - PlaybackTime, - ]), + Node( + t: Container, + content: "Music Player", + width_percent: 100, + height_percent: 100, + children: [Header, Search, PlaybackTime], + ), + ), + #( + Header, + Node( + t: Row, + content: "Foo (1) | Bar (2) | Baz (3)", + width_percent: 100, + height_percent: 33, + children: [], + ), + ), + #( + Search, + Node( + t: Row, + content: "", + width_percent: 100, + height_percent: 33, + children: [], + ), + ), + #( + PlaybackTime, + Node( + t: Row, + content: "00:00", + width_percent: 100, + height_percent: 33, + children: [], + ), ), - #(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: [])), ]) - - Layout(0, 0, nodes: nodes) + Layout(width: 0, height: 0, nodes: nodes) } pub fn update_section( @@ -60,13 +109,123 @@ 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) }) +pub fn render(layout: Layout) -> Nil { + internal.clear_screen() + [layout.width, layout.height] + |> list.map(int.to_string) + |> string.join(" ") + |> string.append("layout - render: ", _) + |> logging.log - internal.print_at(node.content, node.x, node.y) + let container_width = int.to_float(layout.width) + let container_height = int.to_float(layout.height) + 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, + Root, + _, + 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) -> { + let margin = 2.0 + + let width = + container_width *. { int.to_float(node.width_percent) /. 100.0 } + |> float.floor + |> float.truncate + + let height = + container_height *. { int.to_float(node.height_percent) /. 100.0 } + |> float.floor + |> float.truncate + + let #(cx, cy) = case node.t { + Container -> #(container_tl_x, container_tl_y) + 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) + // + 2 for header margin + |> renders.text(node.content, cx + 2, cy) + + list.index_map(node.children, fn(child, i) { #(i, child) }) + |> list.fold(parent, fn(acc_into, ic) { + let #(i, child) = ic + + let cw = + container_width + *. { int.to_float(node.width_percent) /. 100.0 } + -. margin + |> float.floor + + let ch = + container_height + *. { int.to_float(node.height_percent) /. 100.0 } + -. margin + |> float.floor + + let child_origin_x = container_tl_x + 1 + let child_origin_y = container_tl_y + 1 + + render_generic( + layout, + cw, + ch, + 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..6bcfbfa --- /dev/null +++ b/src/musicplayer/ui/layout_examples/layout_examples.gleam @@ -0,0 +1,82 @@ +import gleam/dict + +import musicplayer/ui/internal +import musicplayer/ui/layout.{Container, Layout, Node, Section} +import musicplayer/ui/layout_examples/wait_for_input.{wait_for_input} + +pub fn main() { + let assert Ok(width) = internal.io_get_columns() + let assert Ok(height) = internal.io_get_rows() + + two_rows_with_cells(width, height) + |> layout.render + + wait_for_input() +} + +/// Two rows: +/// First row has two cells +/// Second row has no cells +fn two_rows_with_cells(width: Int, height: Int) -> layout.Layout { + let nodes = + dict.from_list([ + #( + Section("Root"), + Node( + t: Container, + content: "container", + width_percent: 100, + height_percent: 100, + children: [ + Section("Row1"), + Section("Row2"), + ], + ), + ), + #( + Section("Row1"), + Node( + t: layout.Row, + content: "row 1", + width_percent: 100, + height_percent: 50, + children: [ + Section("A"), + Section("A"), + ], + ), + ), + #( + Section("A"), + Node( + t: layout.Cell, + content: "cell 1", + width_percent: 50, + height_percent: 100, + children: [], + ), + ), + #( + Section("B"), + Node( + t: layout.Cell, + content: "cell 2", + width_percent: 50, + height_percent: 100, + children: [], + ), + ), + #( + Section("Row2"), + Node( + t: layout.Row, + content: "row 2", + width_percent: 100, + height_percent: 50, + children: [], + ), + ), + ]) + + Layout(width:, height:, nodes: 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..1dd2c49 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -38,7 +38,7 @@ pub fn new() -> Result(Subject(Control), String) { internal.clear_screen() internal.hide_cursor() - redraw_on_update_loop(redraw) + redraw_loop(redraw) }) Ok(ui) @@ -63,21 +63,18 @@ fn handle_message( |> 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, width, height) + + 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 +85,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..4b8aef5 --- /dev/null +++ b/test/musicplayer/ui/layout_test.gleam @@ -0,0 +1,118 @@ +import gleam/dict +import gleam/io +import gleam/string +import gleeunit +import gleeunit/should +import musicplayer/ui/virtual_ansi + +import musicplayer/ui/layout.{Layout, Node, Section} + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn foo_test() { + let layout = + Layout( + width: 80, + height: 20, + nodes: dict.from_list([ + #( + Section("Root"), + Node( + t: layout.Container, + content: "container", + width_percent: 100, + height_percent: 100, + children: [ + Section("Row1"), + Section("Row2"), + ], + ), + ), + #( + Section("Row1"), + Node( + t: layout.Row, + content: "row 1", + width_percent: 100, + height_percent: 50, + children: [ + Section("A"), + Section("B"), + ], + ), + ), + #( + Section("A"), + Node( + t: layout.Cell, + content: "cell 1", + width_percent: 50, + height_percent: 100, + children: [], + ), + ), + #( + Section("B"), + Node( + t: layout.Cell, + content: "cell 2", + width_percent: 50, + height_percent: 100, + children: [], + ), + ), + #( + Section("Row2"), + Node( + t: layout.Row, + content: "row 1", + width_percent: 100, + height_percent: 50, + children: [], + ), + ), + ]), + ) + + let expected = + " +container──────────────────────────────────────────────────────────────────────┐ +│row 1────────────────────────────────────────────────────────────────────────┐│ +││cell 1───────────────────────────────┐cell 2───────────────────────────────┐││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +│││ ││ │││ +││└────────────────────────────────────┘└────────────────────────────────────┘││ +│└────────────────────────────────────────────────────────────────────────────┘│ +│row 1────────────────────────────────────────────────────────────────────────┐│ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│└────────────────────────────────────────────────────────────────────────────┘│ +└──────────────────────────────────────────────────────────────────────────────┘ +" + + let visual = + virtual_ansi.render(layout, Section("Root"), layout.width, layout.height) + 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..ad8c692 --- /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, type Section, Renders} + +pub type Screen = + dict.Dict(#(Int, Int), String) + +pub fn render(layout: Layout, root: Section, width: Int, height: Int) -> 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(width), + int.to_float(height), + 1, + 1, + 0, + root, + 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) }) +}