diff --git a/src/musicplayer/control.gleam b/src/musicplayer/control.gleam index 386ceeb..6d1f7b8 100644 --- a/src/musicplayer/control.gleam +++ b/src/musicplayer/control.gleam @@ -1,30 +1,58 @@ +import gleam/string +import musicplayer/ui/layout + import musicplayer/input/key.{type Key} +pub type Mode { + Idle + Searching(input: String) +} + pub type Control { TogglePlayPause - Search + Search(input: String, capturing: Bool) - Raw(String) - Return - Backspace + SetView(view_idx: layout.ViewIdx) Exit } -pub fn from_key(key: Key) -> Result(Control, Nil) { +pub fn from_key(key: Key, mode: Mode) -> Result(Control, Nil) { + case mode { + Idle -> idle_from_key(key) + Searching(input) -> searching_from_key(key, input) + } +} + +pub fn idle_from_key(key: Key) -> Result(Control, Nil) { case key { - key.Return -> Ok(Return) - key.Backspace -> Ok(Backspace) - key.Char(char) -> Ok(char_control(char)) + key.Char(char) -> { + case char { + // Views are zero indexed + "1" -> Ok(SetView(0)) + "2" -> Ok(SetView(1)) + + " " -> Ok(TogglePlayPause) + "/" -> Ok(Search(input: "", capturing: True)) + "q" -> Ok(Exit) + + // NOOP + _ -> Error(Nil) + } + } + + // NOOP _ -> Error(Nil) } } -fn char_control(char: String) -> Control { - case char { - " " -> TogglePlayPause - "/" -> Search - "q" -> Exit - _ -> Raw(char) +pub fn searching_from_key(key: Key, input: String) -> Result(Control, Nil) { + case key { + key.Char(char) -> Ok(Search(input <> char, True)) + key.Backspace -> Ok(Search(string.drop_end(input, 1), True)) + key.Return -> Ok(Search(input, False)) + + // NOOP + _ -> Error(Nil) } } diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index efa4774..a3dc53d 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -1,9 +1,8 @@ import gleam/erlang/process.{type Name, type Pid, type Subject} import gleam/otp/actor -import gleam/result import gleam/string -import musicplayer/control.{type Control} +import musicplayer/control.{type Mode} import musicplayer/input/key.{type Key} import musicplayer/logging/logging import musicplayer/mpv/control as mpv_control @@ -11,19 +10,9 @@ import musicplayer/time/time import musicplayer/ui/control as ui_control import musicplayer/ui/layout -type Mode { - Idle - Searching -} - -type Input { - Input(capturing: Bool, content: String) -} - type State { State( mode: Mode, - input: Input, ui: Subject(ui_control.Control), mpv: Subject(mpv_control.Control), ) @@ -36,10 +25,8 @@ pub fn new( ) -> Result(Pid, String) { let input_keys = process.named_subject(input_keys_name) - let input = Input(False, "") - case - actor.new(State(Idle, input, ui, mpv)) + actor.new(State(control.Idle, ui, mpv)) |> actor.on_message(handle_message) |> actor.start { @@ -49,94 +36,77 @@ pub fn new( logging.log("musicplayer - started") process.spawn(fn() { let assert Ok(_) = process.register(process.self(), input_keys_name) - handle_key(musicplayer, input_keys) + forward_key(musicplayer, input_keys) }) - process.spawn(fn() { update_playback_time_loop(mpv, ui, 1000) }) + process.spawn(fn() { update_playback_time_loop(mpv, ui, 250) }) Ok(pid) } } } -fn handle_message(state: State, control: Control) -> actor.Next(State, Control) { - case control { - control.Search -> { - logging.log("musicplayer - initiating search") +fn handle_message(state: State, key: Key) -> actor.Next(State, Key) { + case control.from_key(key, state.mode) { + Error(_) -> actor.continue(state) + Ok(c) -> + case c { + control.SetView(view_idx) -> { + logging.log( + "musicplayer - setting current view to: " + <> string.inspect(view_idx), + ) - update_search(state.ui, "searching: ") + update_current_view(state.ui, view_idx) + actor.continue(state) + } + control.Search(input, capturing) -> { + case capturing { + True -> { + logging.log("musicplayer - searching: " <> input) - actor.continue( - State( - ..state, - mode: Searching, - input: Input(..state.input, capturing: True), - ), - ) - } + update_search(state.ui, "searching: " <> input) - control.Raw(content) -> { - logging.log("musicplayer - recieved raw input: " <> content) + actor.continue(State(..state, mode: control.Searching(input))) + } + False -> { + logging.log( + "musicplayer - recieved return. `input`: " + <> "'" + <> input + <> "'", + ) - let content = case state.mode { - Idle -> state.input.content - Searching -> { - let updated = state.input.content <> content - update_search(state.ui, "searching: " <> updated) - updated + update_search(state.ui, "") + + actor.continue(State(..state, mode: control.Idle)) + } + } + } + control.TogglePlayPause -> { + logging.log("musicplayer - toggling play/pause") + + process.send(state.mpv, mpv_control.TogglePlayPause) + update_playback_time(state.mpv, state.ui) + actor.continue(state) + } + control.Exit -> { + logging.log("musicplayer - initiating musicplayer shutdown") + // Close `mpv` socket + process.call(state.mpv, 1000, fn(reply_to) { + mpv_control.Exit(reply_to) + }) + + // Reset terminal state (show cursor etc.) + process.call(state.ui, 1000, fn(reply_to) { + ui_control.Exit(reply_to) + }) + + logging.log("musicplayer - stopped") + + actor.stop() } } - - actor.continue(State(..state, input: Input(..state.input, content:))) - } - control.Backspace -> { - logging.log("musicplayer - recieved backspace") - - let content = case state.mode { - Idle -> state.input.content - Searching -> string.drop_end(state.input.content, 1) - } - actor.continue(State(..state, input: Input(..state.input, content:))) - } - control.Return -> { - logging.log( - "musicplayer - recieved return. `input.capture`: " - <> "'" - <> state.input.content - <> "'", - ) - - // Note: state.input.content is now the final input, use it - // before it is reset - case state.mode { - Idle -> Nil - Searching -> update_search(state.ui, "") - } - - actor.continue( - State(..state, mode: Idle, input: Input(capturing: False, content: "")), - ) - } - - control.TogglePlayPause -> { - logging.log("musicplayer - toggling play/pause") - - process.send(state.mpv, mpv_control.TogglePlayPause) - update_playback_time(state.mpv, state.ui) - actor.continue(state) - } - control.Exit -> { - logging.log("musicplayer - initiating musicplayer shutdown") - // Close `mpv` socket - process.call(state.mpv, 1000, fn(reply_to) { mpv_control.Exit(reply_to) }) - - // Reset terminal state (show cursor etc.) - process.call(state.ui, 1000, fn(reply_to) { ui_control.Exit(reply_to) }) - - logging.log("musicplayer - stopped") - - actor.stop() - } } } @@ -185,14 +155,21 @@ fn update_search(ui: Subject(ui_control.Control), content: String) -> Nil { process.send(ui, ui_control.UpdateState(layout.Search, content)) } -/// `handle_key` listens to a subject onto which `input` will send messages with `Key`s -fn handle_key(musicplayer: Subject(Control), input_keys: Subject(Key)) -> Nil { +fn update_current_view( + ui: Subject(ui_control.Control), + view_idx: layout.ViewIdx, +) { + process.send(ui, ui_control.SetView(view_idx)) +} + +/// `forward_key` listens to a subject onto which `input` will send messages with `Key`s +/// that is then forwarded to the `musicplayer` agent to handle +fn forward_key(musicplayer: Subject(Key), input_keys: Subject(Key)) -> Nil { let _ = process.new_selector() |> process.select(input_keys) |> process.selector_receive_forever - |> control.from_key - |> result.map(process.send(musicplayer, _)) + |> process.send(musicplayer, _) - handle_key(musicplayer, input_keys) + forward_key(musicplayer, input_keys) } diff --git a/src/musicplayer/ui/ansi.gleam b/src/musicplayer/ui/ansi.gleam deleted file mode 100644 index 4acabf7..0000000 --- a/src/musicplayer/ui/ansi.gleam +++ /dev/null @@ -1,42 +0,0 @@ -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 bb9a501..a800761 100644 --- a/src/musicplayer/ui/control.gleam +++ b/src/musicplayer/ui/control.gleam @@ -1,10 +1,11 @@ import gleam/erlang/process.{type Subject} -import musicplayer/ui/layout.{type Section} +import musicplayer/ui/layout.{type Section, type ViewIdx} pub type Control { UpdateDimensions(columns: Int, rows: Int) UpdateState(section: Section, content: String) + SetView(view_idx: ViewIdx) Exit(reply_to: Subject(Nil)) } diff --git a/src/musicplayer/ui/internal.gleam b/src/musicplayer/ui/internal.gleam index 2e8a8af..3f7acc8 100644 --- a/src/musicplayer/ui/internal.gleam +++ b/src/musicplayer/ui/internal.gleam @@ -3,6 +3,16 @@ import gleam/io // https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands +pub const move_to_home = "\u{001B}[H" + +pub const disable_auto_wrap = "\u{001B}[?7l" + +pub const enable_auto_wrap = "\u{001B}[?7h" + +pub fn update(frame: String) -> Nil { + io.print(disable_auto_wrap <> move_to_home <> frame <> enable_auto_wrap) +} + pub fn clear_screen() -> Nil { io.print("\u{001B}[2J\u{001B}[H") } @@ -12,10 +22,6 @@ pub fn chars_at(chars: String, x: Int, y: Int) -> String { seq <> chars } -pub fn print(chars: String) -> Nil { - io.print(chars) -} - pub fn hide_cursor() -> Nil { io.print("\u{001B}[?25l") } diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index 457983e..ff845f6 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -4,11 +4,9 @@ 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 +import musicplayer/ui/plot.{type Buffer} pub const root_section = "reserved_root_section" @@ -34,13 +32,41 @@ pub type Node { Cell(content: String, style: Style) } +pub type ViewIdx = + Int + +pub type View = + dict.Dict(Section, Node) + +// Layout consists of a list Views, and only one View is rendered at a time pub type Layout { - Layout(columns: Int, rows: Int, nodes: dict.Dict(Section, Node)) + Layout( + columns: Int, + rows: Int, + current_view: ViewIdx, + views: dict.Dict(ViewIdx, View), + ) } -pub fn new(columns: Int, rows: Int, nodes: List(#(Section, Node))) -> Layout { +pub fn new( + columns: Int, + rows: Int, + views: List(List(#(Section, Node))), +) -> Layout { + let views = + list.index_map(views, fn(view_nodes, i) { #(i, view_nodes) }) + |> list.fold(dict.new(), fn(view_acc, iv) { + let #(i, view_nodes) = iv + + dict.insert(view_acc, i, view_loop(i, view_nodes)) + }) + + Layout(columns:, rows:, current_view: 0, views:) +} + +fn view_loop(i: ViewIdx, view_nodes: List(#(Section, Node))) -> View { let children = - nodes + view_nodes |> list.flat_map(fn(node) { case pair.second(node) { Row(children: c, ..) -> c @@ -52,22 +78,24 @@ pub fn new(columns: Int, rows: Int, nodes: List(#(Section, Node))) -> Layout { // All sections that are not children of other nodes will be added as // children to the root let orphans = - nodes + view_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, - ), - ) + dict.from_list(view_nodes) + |> dict.insert( + view_index_section(i), + Row( + content: "", + style: Style(dimensions: Percent(width: 100, height: 100)), + children: orphans, + ), + ) +} - Layout(columns:, rows:, nodes:) +/// Takes a ViewIndex and create a Section key from it +fn view_index_section(view_idx: ViewIdx) -> Section { + Section(string.append("view_", int.to_string(view_idx))) } pub fn update_section( @@ -75,16 +103,25 @@ pub fn update_section( section: Section, content: String, ) -> Layout { - case dict.get(layout.nodes, section) { + case dict.get(layout.views, layout.current_view) { Error(_) -> layout - Ok(node) -> { - let updated = case node { - Cell(..) -> Cell(..node, content: content) - Row(..) -> Row(..node, content: content) - } + Ok(view) -> + case dict.get(view, section) { + Error(_) -> layout + Ok(node) -> { + let updated_node = case node { + Cell(..) -> Cell(..node, content: content) + Row(..) -> Row(..node, content: content) + } - Layout(..layout, nodes: dict.insert(layout.nodes, section, updated)) - } + let updated_view = dict.insert(view, section, updated_node) + + Layout( + ..layout, + views: dict.insert(layout.views, layout.current_view, updated_view), + ) + } + } } } @@ -92,15 +129,11 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { Layout(..layout, columns:, rows:) } +pub fn update_current_view(layout: Layout, view_idx: ViewIdx) -> Layout { + Layout(..layout, current_view: view_idx) +} + 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, @@ -110,29 +143,21 @@ pub fn render(layout: Layout) -> Nil { 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)) - }, - ) + case dict.get(layout.views, layout.current_view) { + Error(_) -> Nil + Ok(view) -> { + let buffer: Buffer = dict.new() - 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, - ) + render_loop( + view, + context, + view_index_section(layout.current_view), + buffer, + ) + |> plot.flush_buffer(layout.columns, layout.rows) + |> internal.update + } + } } pub type RenderContext { @@ -145,17 +170,16 @@ pub type RenderContext { ) } -pub fn render_generic( - layout: Layout, +pub fn render_loop( + view: View, context: RenderContext, from: Section, - render_into: into, - renders: Renders(into), -) -> into { - case dict.get(layout.nodes, from) { - Error(_) -> render_into + buffer: Buffer, +) -> Buffer { + case dict.get(view, from) { + Error(_) -> buffer Ok(node) -> { - // Margin between container and the node being rendere + // Margin between container and the node being rendered let margin = 2 let #(node_width, node_height) = case node.style.dimensions { @@ -180,20 +204,20 @@ pub fn render_generic( } let parent = - render_into - |> renders.box( + plot.box( + buffer, node_top_left_x, node_top_left_y, node_width, node_height, ) - |> renders.text(node.content, node_top_left_x, node_top_left_y) + |> plot.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) { + |> list.fold(parent, fn(acc_buffer, ic) { let #(i, child) = ic let #(child_width, child_height) = case node.style.dimensions { @@ -215,7 +239,7 @@ pub fn render_generic( position_index: i, ) - render_generic(layout, context, child, acc_into, renders) + render_loop(view, context, child, acc_buffer) }) } } diff --git a/src/musicplayer/ui/layout_examples/layout_examples.gleam b/src/musicplayer/ui/layout_examples/layout_examples.gleam index 2c1b39c..55e127c 100644 --- a/src/musicplayer/ui/layout_examples/layout_examples.gleam +++ b/src/musicplayer/ui/layout_examples/layout_examples.gleam @@ -16,41 +16,43 @@ pub fn main() { /// 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"), - ], + let views = [ + [ + #( + 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("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("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: [], + #( + Section("Row2"), + layout.Row( + content: "row 2", + style: Style(dimensions: Percent(width: 50, height: 50)), + children: [], + ), ), - ), + ], ] - layout.new(columns, rows, nodes) + layout.new(columns, rows, views) } diff --git a/src/musicplayer/ui/plot.gleam b/src/musicplayer/ui/plot.gleam new file mode 100644 index 0000000..4868bf3 --- /dev/null +++ b/src/musicplayer/ui/plot.gleam @@ -0,0 +1,72 @@ +import gleam/dict.{type Dict} +import gleam/list +import gleam/string + +pub type Buffer = + Dict(#(Int, Int), String) + +pub fn flush_buffer(buffer: Buffer, columns: Int, rows: Int) -> String { + list.range(1, rows) + |> list.map(fn(y) { + list.range(1, columns) + |> list.map(fn(x) { + case dict.get(buffer, #(x, y)) { + Ok(char) -> char + Error(_) -> " " + } + }) + |> string.join("") + }) + |> string.join("\r\n") +} + +pub fn text(buffer: Buffer, text: String, x: Int, y: Int) -> Buffer { + text + |> string.to_graphemes + |> list.index_fold(buffer, fn(acc, char, i) { + dict.insert(acc, #(x + i, y), char) + }) +} + +pub fn box(buffer: Buffer, x: Int, y: Int, width: Int, height: Int) -> Buffer { + // TODO move box style to `layout.Style` + let box_chars = #("┌", "┐", "└", "┘", "─", "│") + let #(tl, tr, bl, br, hor, ver) = box_chars + + case width < 2 || height < 2 { + True -> buffer + False -> { + buffer + |> dict.insert(#(x, y), tl) + |> dict.insert(#(x + width - 1, y), tr) + |> dict.insert(#(x, y + height - 1), bl) + |> dict.insert(#(x + width - 1, y + height - 1), br) + |> horizontal_line(x + 1, y, width - 2, hor) + |> horizontal_line(x + 1, y + height - 1, width - 2, hor) + |> vertical_line(x, y + 1, height - 2, ver) + |> vertical_line(x + width - 1, y + 1, height - 2, ver) + } + } +} + +fn horizontal_line( + buffer: Buffer, + x: Int, + y: Int, + len: Int, + char: String, +) -> Buffer { + list.range(0, len - 1) + |> list.fold(buffer, fn(acc, i) { dict.insert(acc, #(x + i, y), char) }) +} + +fn vertical_line( + buffer: Buffer, + x: Int, + y: Int, + len: Int, + char: String, +) -> Buffer { + list.range(0, len - 1) + |> list.fold(buffer, fn(acc, i) { dict.insert(acc, #(x, y + i), char) }) +} diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam index 84910ec..a12fede 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -19,30 +19,65 @@ pub fn new() -> Result(Subject(Control), String) { 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.Header, + layout.Row( + content: "Foo <1> | Bar (2)", + style: layout.Style(dimensions: layout.Percent( + width: 100, + height: 50, + )), + 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: 50, + )), + children: [], + ), ), - ), - #( - layout.PlaybackTime, - layout.Row( - content: "00:00", - style: layout.Style(dimensions: layout.Percent(width: 100, height: 33)), - children: [], + ], + [ + #( + layout.Header, + layout.Row( + content: "Foo (1) | Bar <2>", + 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, _) @@ -109,6 +144,12 @@ fn handle_message( process.send(reply_to, Nil) actor.stop() } + control.SetView(view_idx) -> { + let layout = layout.update_current_view(state.layout, view_idx) + + actor.send(state.redraw, layout) + actor.continue(State(..state, layout:)) + } } } diff --git a/test/musicplayer/control_test.gleam b/test/musicplayer/control_test.gleam index ab75436..2c04887 100644 --- a/test/musicplayer/control_test.gleam +++ b/test/musicplayer/control_test.gleam @@ -1,7 +1,7 @@ import gleam/list import gleeunit -import musicplayer/control.{type Control} +import musicplayer/control.{type Control, type Mode} import musicplayer/input/key.{type Key, Char} pub fn main() -> Nil { @@ -9,16 +9,24 @@ pub fn main() -> Nil { } type TestCase { - TestCase(key: Key, expected: Result(Control, Nil)) + TestCase(key: Key, mode: Mode, expected: Result(Control, Nil)) } pub fn control_from_key_test() { - let test_cases = [ - TestCase(Char(" "), Ok(control.TogglePlayPause)), - TestCase(Char("q"), Ok(control.Exit)), + let idle_tests = [ + TestCase(Char(" "), control.Idle, Ok(control.TogglePlayPause)), + TestCase(Char("/"), control.Idle, Ok(control.Search("", True))), + TestCase(Char("q"), control.Idle, Ok(control.Exit)), ] - list.each(test_cases, fn(tc) { - assert tc.expected == control.from_key(tc.key) + let search_tests = [ + TestCase(Char("a"), control.Searching(""), Ok(control.Search("a", True))), + TestCase(Char("b"), control.Searching("a"), Ok(control.Search("ab", True))), + ] + + let test_cases = [idle_tests, search_tests] + + list.each(list.flatten(test_cases), fn(tc) { + assert tc.expected == control.from_key(tc.key, tc.mode) }) } diff --git a/test/musicplayer/ui/layout_test.gleam b/test/musicplayer/ui/layout_test.gleam index 882871e..871889e 100644 --- a/test/musicplayer/ui/layout_test.gleam +++ b/test/musicplayer/ui/layout_test.gleam @@ -1,55 +1,58 @@ +import gleam/dict import gleam/io import gleam/string import gleeunit import gleeunit/should -import musicplayer/ui/virtual_ansi -import musicplayer/ui/layout.{Percent, Section, Style} +import musicplayer/ui/layout.{Percent, RenderContext, Section, Style} +import musicplayer/ui/plot 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"), - ], + let views = [ + [ + #( + 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("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("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: [], + #( + 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 layout = layout.new(columns, rows, views) let expected = " @@ -74,9 +77,30 @@ pub fn percent_layout_test() { │└────────────────────────────────────────────────────────────────────────────┘│ └──────────────────────────────────────────────────────────────────────────────┘ " + |> string.replace(each: "\n", with: "\r\n") + |> string.trim - let visual = virtual_ansi.render(layout) - case visual == string.trim(expected) { + 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 assert Ok(view) = dict.get(layout.views, layout.current_view) + + let flushed = + layout.render_loop( + view, + context, + Section(string.append("view_", string.inspect(layout.current_view))), + dict.new(), + ) + |> plot.flush_buffer(layout.columns, layout.rows) + + case flushed == expected { True -> Nil False -> { io.println("Test failed") @@ -84,9 +108,9 @@ pub fn percent_layout_test() { io.println(string.trim(expected)) io.println("Got:") - io.println(visual) + io.println(flushed) - should.equal(visual, expected) + should.equal(flushed, expected) } } } diff --git a/test/musicplayer/ui/virtual_ansi.gleam b/test/musicplayer/ui/virtual_ansi.gleam deleted file mode 100644 index 5a1bf9a..0000000 --- a/test/musicplayer/ui/virtual_ansi.gleam +++ /dev/null @@ -1,118 +0,0 @@ -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) }) -}