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/layout.gleam b/src/musicplayer/ui/layout.gleam index 98dbcad..bf0af6b 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -6,6 +6,7 @@ import gleam/string import gleam/string_tree import musicplayer/logging/logging +import musicplayer/ui/ansi import musicplayer/ui/internal pub type Section { @@ -45,7 +46,7 @@ pub fn new() -> Layout { Section("Root"), Node( t: Container, - content: "container", + content: "", width_percent: 100, height_percent: 100, children: [ @@ -63,7 +64,7 @@ pub fn new() -> Layout { height_percent: 50, children: [ Section("A"), - Section("A"), + Section("B"), ], ), ), @@ -91,7 +92,7 @@ pub fn new() -> Layout { Section("Row2"), Node( t: Row, - content: "row 1", + content: "row 2", width_percent: 100, height_percent: 50, children: [], @@ -138,13 +139,13 @@ pub fn render(layout: Layout) -> Nil { let container_top_left_x = 1 let container_top_left_y = 1 - let ansi_ops = - RenderOps( - draw_box: fn(tree, x, y, w, h) { - string_tree.append(tree, draw_box(x, y, w, h)) + let ansi_renders = + Renders( + box: fn(tree, x, y, w, h) { + string_tree.append(tree, ansi.box(x, y, w, h)) }, - draw_text: fn(tree, text, x, y) { - string_tree.append(tree, internal.chars_at(text, x, y)) + text: fn(tree, chars, x, y) { + string_tree.append(tree, ansi.text(chars, x, y)) }, ) @@ -158,16 +159,16 @@ pub fn render(layout: Layout) -> Nil { 0, Section("Root"), _, - ansi_ops, + ansi_renders, ) |> string_tree.to_string |> internal.print } -pub type RenderOps(ctx) { - RenderOps( - draw_box: fn(ctx, Int, Int, Int, Int) -> ctx, - draw_text: fn(ctx, String, Int, Int) -> ctx, +pub type Renders(into) { + Renders( + text: fn(into, String, Int, Int) -> into, + box: fn(into, Int, Int, Int, Int) -> into, ) } @@ -181,15 +182,12 @@ pub fn render_generic( // State index: Int, from: Section, - current_ctx: ctx, - // <--- Generic State - ops: RenderOps(ctx), - // <--- The Strategy -) -> ctx { + render_into: into, + renders: Renders(into), +) -> into { case dict.get(layout.nodes, from) { - Error(_) -> current_ctx + Error(_) -> render_into Ok(node) -> { - // --- 1. MATH (Shared Logic) --- let margin = 2.0 let width = @@ -208,16 +206,13 @@ pub fn render_generic( Cell -> #(container_tl_x + { index * width }, container_tl_y) } - // --- 2. RENDER PARENT (Using Generic Ops) --- - // We modify the context using the provided functions - let ctx_with_parent = - current_ctx - |> ops.draw_box(cx, cy, width, height) - |> ops.draw_text(node.content, cx, cy) + let parent = + render_into + |> renders.box(cx, cy, width, height) + |> renders.text(node.content, cx, cy) - // --- 3. RECURSE CHILDREN --- list.index_map(node.children, fn(child, i) { #(i, child) }) - |> list.fold(ctx_with_parent, fn(acc_ctx, ic) { + |> list.fold(parent, fn(acc_into, ic) { let #(i, child) = ic let cw = @@ -243,43 +238,10 @@ pub fn render_generic( child_origin_y, i, child, - acc_ctx, - ops, + acc_into, + renders, ) }) } } } - -fn draw_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/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/one.gleam b/src/musicplayer/ui/layout_examples/one.gleam deleted file mode 100644 index 85c87bf..0000000 --- a/src/musicplayer/ui/layout_examples/one.gleam +++ /dev/null @@ -1,3 +0,0 @@ -pub fn main() { - echo "hello" -} 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/test/musicplayer/ui/layout_test.gleam b/test/musicplayer/ui/layout_test.gleam index be68444..4b8aef5 100644 --- a/test/musicplayer/ui/layout_test.gleam +++ b/test/musicplayer/ui/layout_test.gleam @@ -1,13 +1,11 @@ import gleam/dict -import gleam/float -import gleam/int -import gleam/list +import gleam/io import gleam/string import gleeunit +import gleeunit/should +import musicplayer/ui/virtual_ansi -import musicplayer/ui/layout.{ - type Layout, type Section, Layout, Node, RenderOps, Section, -} +import musicplayer/ui/layout.{Layout, Node, Section} pub fn main() -> Nil { gleeunit.main() @@ -102,133 +100,19 @@ container─────────────────────── └──────────────────────────────────────────────────────────────────────────────┘ " - let visual = render_to_visual(layout, Section("Root"), 80, 20) - assert visual == string.trim(expected) -} - -pub type Screen = - dict.Dict(#(Int, Int), String) - -pub fn render_to_visual( - layout: Layout, - root: Section, - width: Int, - height: Int, -) -> String { - // 1. Define the Strategy: How to draw on a Dict - let test_ops = - RenderOps( - draw_box: fn(screen, x, y, w, h) { plot_box(screen, x, y, w, h) }, - draw_text: fn(screen, text, x, y) { plot_text(screen, text, x, y) }, - ) - - // 2. Run the generic logic (reusing the exact math from your real app) - let final_screen = - layout.render_generic( - layout, - int.to_float(width), - int.to_float(height), - 1, - // Start X (1-based for ANSI compatibility) - 1, - // Start Y - 0, - // Root index - root, - dict.new(), - // Initial Context - test_ops, - ) - - // 3. Convert the Grid to a Visual String - screen_to_string(final_screen) -} - -// ---------------------------------------------------------------------------- -// Plotting Primitives (The "Graphics Engine" for Tests) -// ---------------------------------------------------------------------------- - -fn plot_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) - }) -} - -fn plot_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 + let visual = + virtual_ansi.render(layout, Section("Root"), layout.width, layout.height) + case visual == string.trim(expected) { + True -> Nil 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 - |> plot_line_hor(x + 1, y, w - 2, hor) - |> plot_line_hor(x + 1, y + h - 1, w - 2, hor) - // 3. Side edges - |> plot_line_ver(x, y + 1, h - 2, ver) - |> plot_line_ver(x + w - 1, y + 1, h - 2, ver) + io.println("Test failed") + io.println("Expected:") + io.println(string.trim(expected)) + + io.println("Got:") + io.println(visual) + + should.equal(visual, expected) } } } - -fn plot_line_hor( - 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 plot_line_ver( - 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) }) -} - -// ---------------------------------------------------------------------------- -// Output Formatting -// ---------------------------------------------------------------------------- - -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") -} 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) }) +}