From ed69566f6fb6aed9dff2d52bb8b3ae755f70aab4 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sat, 20 Dec 2025 14:05:25 +0100 Subject: [PATCH 1/6] Plot layout on a grid Redraw all x,y coordinates on screen Instead of using ANSI codes, to be avoid clearing the screen which introduces flickering in TMUX --- src/musicplayer/musicplayer.gleam | 2 +- src/musicplayer/ui/ansi.gleam | 42 --------- src/musicplayer/ui/internal.gleam | 14 ++- src/musicplayer/ui/layout.gleam | 52 ++++------- src/musicplayer/ui/plot.gleam | 71 +++++++++++++++ test/musicplayer/ui/layout_test.gleam | 32 +++++-- test/musicplayer/ui/virtual_ansi.gleam | 118 ------------------------- 7 files changed, 123 insertions(+), 208 deletions(-) delete mode 100644 src/musicplayer/ui/ansi.gleam create mode 100644 src/musicplayer/ui/plot.gleam delete mode 100644 test/musicplayer/ui/virtual_ansi.gleam diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index efa4774..075fc95 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -52,7 +52,7 @@ pub fn new( handle_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) } 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/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..22fc82a 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -4,11 +4,10 @@ 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" @@ -93,8 +92,6 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { } pub fn render(layout: Layout) -> Nil { - internal.clear_screen() - [layout.columns, layout.rows] |> list.map(int.to_string) |> string.join(" ") @@ -110,29 +107,11 @@ 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)) - }, - ) + 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(layout, context, Section(root_section), buffer) + |> plot.flush_buffer(layout.columns, layout.rows) + |> internal.update } pub type RenderContext { @@ -145,17 +124,16 @@ pub type RenderContext { ) } -pub fn render_generic( +pub fn render_loop( layout: Layout, context: RenderContext, from: Section, - render_into: into, - renders: Renders(into), -) -> into { + buffer: Buffer, +) -> Buffer { case dict.get(layout.nodes, from) { - Error(_) -> render_into + 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 +158,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 +193,7 @@ pub fn render_generic( position_index: i, ) - render_generic(layout, context, child, acc_into, renders) + render_loop(layout, context, child, acc_buffer) }) } } diff --git a/src/musicplayer/ui/plot.gleam b/src/musicplayer/ui/plot.gleam new file mode 100644 index 0000000..91d1774 --- /dev/null +++ b/src/musicplayer/ui/plot.gleam @@ -0,0 +1,71 @@ +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 { + 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/test/musicplayer/ui/layout_test.gleam b/test/musicplayer/ui/layout_test.gleam index 882871e..1b6a3c6 100644 --- a/test/musicplayer/ui/layout_test.gleam +++ b/test/musicplayer/ui/layout_test.gleam @@ -1,10 +1,11 @@ +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() @@ -74,9 +75,28 @@ 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 flushed = + layout.render_loop( + layout, + context, + Section(layout.root_section), + dict.new(), + ) + |> plot.flush_buffer(layout.columns, layout.rows) + + case flushed == expected { True -> Nil False -> { io.println("Test failed") @@ -84,9 +104,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) }) -} -- 2.52.0 From 95eaeb60f419fef051b03fbce1438d919fe79405 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 21 Dec 2025 13:21:14 +0100 Subject: [PATCH 2/6] Layout consist of multiple Views --- src/musicplayer/ui/layout.gleam | 106 +++++++++++++----- .../ui/layout_examples/layout_examples.gleam | 64 ++++++----- src/musicplayer/ui/ui.gleam | 53 +++++---- test/musicplayer/ui/layout_test.gleam | 70 ++++++------ 4 files changed, 177 insertions(+), 116 deletions(-) diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index 22fc82a..34978eb 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -33,13 +33,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 @@ -51,22 +79,19 @@ 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, - ), - ) - - Layout(columns:, rows:, nodes:) + dict.from_list(view_nodes) + |> dict.insert( + Section(string.append("view_", string.inspect(i))), + Row( + content: "", + style: Style(dimensions: Percent(width: 100, height: 100)), + children: orphans, + ), + ) } pub fn update_section( @@ -74,16 +99,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), + ) + } + } } } @@ -107,11 +141,21 @@ pub fn render(layout: Layout) -> Nil { position_index: 0, ) - let buffer: Buffer = dict.new() + case dict.get(layout.views, layout.current_view) { + Error(_) -> Nil + Ok(view) -> { + let buffer: Buffer = dict.new() - render_loop(layout, context, Section(root_section), buffer) - |> plot.flush_buffer(layout.columns, layout.rows) - |> internal.update + render_loop( + view, + context, + Section(string.append("view_", string.inspect(layout.current_view))), + buffer, + ) + |> plot.flush_buffer(layout.columns, layout.rows) + |> internal.update + } + } } pub type RenderContext { @@ -125,12 +169,12 @@ pub type RenderContext { } pub fn render_loop( - layout: Layout, + view: View, context: RenderContext, from: Section, buffer: Buffer, ) -> Buffer { - case dict.get(layout.nodes, from) { + case dict.get(view, from) { Error(_) -> buffer Ok(node) -> { // Margin between container and the node being rendered @@ -193,7 +237,7 @@ pub fn render_loop( position_index: i, ) - render_loop(layout, context, child, acc_buffer) + 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/ui.gleam b/src/musicplayer/ui/ui.gleam index 84910ec..4d7cf2d 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -19,30 +19,41 @@ 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) | 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.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.PlaybackTime, + layout.Row( + content: "00:00", + style: layout.Style(dimensions: layout.Percent( + width: 100, + height: 33, + )), + children: [], + ), ), - ), + ], ] |> layout.new(0, 0, _) diff --git a/test/musicplayer/ui/layout_test.gleam b/test/musicplayer/ui/layout_test.gleam index 1b6a3c6..871889e 100644 --- a/test/musicplayer/ui/layout_test.gleam +++ b/test/musicplayer/ui/layout_test.gleam @@ -12,45 +12,47 @@ pub fn main() -> Nil { } 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 = " @@ -87,11 +89,13 @@ pub fn percent_layout_test() { position_index: 0, ) + let assert Ok(view) = dict.get(layout.views, layout.current_view) + let flushed = layout.render_loop( - layout, + view, context, - Section(layout.root_section), + Section(string.append("view_", string.inspect(layout.current_view))), dict.new(), ) |> plot.flush_buffer(layout.columns, layout.rows) -- 2.52.0 From 9d54495a4b435143652a29be3a3fcd9ab070522f Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 21 Dec 2025 17:11:36 +0100 Subject: [PATCH 3/6] Update search when `Backspace` is received --- src/musicplayer/musicplayer.gleam | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 075fc95..525c0be 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -94,7 +94,11 @@ fn handle_message(state: State, control: Control) -> actor.Next(State, Control) let content = case state.mode { Idle -> state.input.content - Searching -> string.drop_end(state.input.content, 1) + Searching -> { + let updated = string.drop_end(state.input.content, 1) + update_search(state.ui, "searching: " <> updated) + updated + } } actor.continue(State(..state, input: Input(..state.input, content:))) } -- 2.52.0 From 0ef94d7c89bfb42701c57c9f9ef4e2b7f2ff82fe Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 25 Dec 2025 17:49:34 +0100 Subject: [PATCH 4/6] Key handling is state aware By forwarding all `Key`s to the agent and allow it to decide what should be done, instead of converting the `Key` to a `Control` and then decide what should be done --- src/musicplayer/control.gleam | 51 +++++++--- src/musicplayer/musicplayer.gleam | 151 ++++++++++------------------ src/musicplayer/ui/layout.gleam | 9 +- src/musicplayer/ui/plot.gleam | 1 + test/musicplayer/control_test.gleam | 22 ++-- 5 files changed, 107 insertions(+), 127 deletions(-) diff --git a/src/musicplayer/control.gleam b/src/musicplayer/control.gleam index 386ceeb..3246f00 100644 --- a/src/musicplayer/control.gleam +++ b/src/musicplayer/control.gleam @@ -1,30 +1,51 @@ +import gleam/string + import musicplayer/input/key.{type Key} +pub type Mode { + Idle + Searching(input: String) +} + pub type Control { TogglePlayPause - Search - - Raw(String) - Return - Backspace + Search(input: String, capturing: Bool) 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 { + " " -> 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 525c0be..31d006c 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,7 +36,7 @@ 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, 250) }) @@ -59,88 +46,58 @@ pub fn new( } } -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.Search(input, capturing) -> { + case capturing { + True -> { + logging.log("musicplayer - searching: " <> input) - update_search(state.ui, "searching: ") + update_search(state.ui, "searching: " <> input) - actor.continue( - State( - ..state, - mode: Searching, - input: Input(..state.input, capturing: True), - ), - ) - } + actor.continue(State(..state, mode: control.Searching(input))) + } + False -> { + logging.log( + "musicplayer - recieved return. `input`: " + <> "'" + <> input + <> "'", + ) - control.Raw(content) -> { - logging.log("musicplayer - recieved raw input: " <> content) + update_search(state.ui, "") - let content = case state.mode { - Idle -> state.input.content - Searching -> { - let updated = state.input.content <> content - update_search(state.ui, "searching: " <> updated) - updated + 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 -> { - let updated = string.drop_end(state.input.content, 1) - update_search(state.ui, "searching: " <> updated) - updated - } - } - 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() - } } } @@ -189,14 +146,14 @@ 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 { +/// `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/layout.gleam b/src/musicplayer/ui/layout.gleam index 34978eb..73d7698 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,11 +1,9 @@ import gleam/dict -import gleam/int import gleam/list import gleam/pair import gleam/set import gleam/string -import musicplayer/logging/logging import musicplayer/ui/internal import musicplayer/ui/plot.{type Buffer} @@ -125,13 +123,8 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { Layout(..layout, columns:, rows:) } -pub fn render(layout: Layout) -> Nil { - [layout.columns, layout.rows] - |> list.map(int.to_string) - |> string.join(" ") - |> string.append("layout - render: ", _) - |> logging.log +pub fn render(layout: Layout) -> Nil { let context = RenderContext( parent_width: layout.columns, diff --git a/src/musicplayer/ui/plot.gleam b/src/musicplayer/ui/plot.gleam index 91d1774..4868bf3 100644 --- a/src/musicplayer/ui/plot.gleam +++ b/src/musicplayer/ui/plot.gleam @@ -29,6 +29,7 @@ pub fn text(buffer: Buffer, text: String, x: Int, y: Int) -> Buffer { } 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 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) }) } -- 2.52.0 From a3c5c203f12ca297f7a68d99a4c9911ee23b147a Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 25 Dec 2025 17:51:32 +0100 Subject: [PATCH 5/6] wip set view --- src/musicplayer/ui/control.gleam | 1 + src/musicplayer/ui/layout.gleam | 4 ++++ src/musicplayer/ui/ui.gleam | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/src/musicplayer/ui/control.gleam b/src/musicplayer/ui/control.gleam index bb9a501..1276950 100644 --- a/src/musicplayer/ui/control.gleam +++ b/src/musicplayer/ui/control.gleam @@ -5,6 +5,7 @@ import musicplayer/ui/layout.{type Section} pub type Control { UpdateDimensions(columns: Int, rows: Int) UpdateState(section: Section, content: String) + SetView(view_idx: layout.ViewIdx) Exit(reply_to: Subject(Nil)) } diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index 73d7698..6d1b919 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -123,6 +123,9 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { Layout(..layout, columns:, rows:) } +pub fn set_view(layout: Layout, view_idx: ViewIdx) -> Layout { + Layout(..layout, current_view: view_idx) +} pub fn render(layout: Layout) -> Nil { let context = @@ -142,6 +145,7 @@ pub fn render(layout: Layout) -> Nil { render_loop( view, context, + // TODO extract to function `view_index` Section(string.append("view_", string.inspect(layout.current_view))), buffer, ) diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam index 4d7cf2d..ddd5898 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -120,6 +120,12 @@ fn handle_message( process.send(reply_to, Nil) actor.stop() } + control.SetView(view_idx) -> { + let layout = layout.set_view(state.layout, view_idx) + + actor.send(state.redraw, layout) + actor.continue(State(..state, layout:)) + } } } -- 2.52.0 From 0c4c085dd0cb8f8df7efa14cc919371cc2354508 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 25 Dec 2025 19:49:08 +0100 Subject: [PATCH 6/6] Add ability to switch View --- src/musicplayer/control.gleam | 7 +++++++ src/musicplayer/musicplayer.gleam | 16 ++++++++++++++++ src/musicplayer/ui/control.gleam | 4 ++-- src/musicplayer/ui/layout.gleam | 13 +++++++++---- src/musicplayer/ui/ui.gleam | 28 ++++++++++++++++++++++++++-- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/musicplayer/control.gleam b/src/musicplayer/control.gleam index 3246f00..6d1f7b8 100644 --- a/src/musicplayer/control.gleam +++ b/src/musicplayer/control.gleam @@ -1,4 +1,5 @@ import gleam/string +import musicplayer/ui/layout import musicplayer/input/key.{type Key} @@ -11,6 +12,8 @@ pub type Control { TogglePlayPause Search(input: String, capturing: Bool) + SetView(view_idx: layout.ViewIdx) + Exit } @@ -25,6 +28,10 @@ pub fn idle_from_key(key: Key) -> Result(Control, Nil) { case key { 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) diff --git a/src/musicplayer/musicplayer.gleam b/src/musicplayer/musicplayer.gleam index 31d006c..a3dc53d 100644 --- a/src/musicplayer/musicplayer.gleam +++ b/src/musicplayer/musicplayer.gleam @@ -51,6 +51,15 @@ fn handle_message(state: State, key: Key) -> actor.Next(State, Key) { Error(_) -> actor.continue(state) Ok(c) -> case c { + control.SetView(view_idx) -> { + logging.log( + "musicplayer - setting current view to: " + <> string.inspect(view_idx), + ) + + update_current_view(state.ui, view_idx) + actor.continue(state) + } control.Search(input, capturing) -> { case capturing { True -> { @@ -146,6 +155,13 @@ fn update_search(ui: Subject(ui_control.Control), content: String) -> Nil { process.send(ui, ui_control.UpdateState(layout.Search, content)) } +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 { diff --git a/src/musicplayer/ui/control.gleam b/src/musicplayer/ui/control.gleam index 1276950..a800761 100644 --- a/src/musicplayer/ui/control.gleam +++ b/src/musicplayer/ui/control.gleam @@ -1,11 +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: layout.ViewIdx) + SetView(view_idx: ViewIdx) Exit(reply_to: Subject(Nil)) } diff --git a/src/musicplayer/ui/layout.gleam b/src/musicplayer/ui/layout.gleam index 6d1b919..ff845f6 100644 --- a/src/musicplayer/ui/layout.gleam +++ b/src/musicplayer/ui/layout.gleam @@ -1,4 +1,5 @@ import gleam/dict +import gleam/int import gleam/list import gleam/pair import gleam/set @@ -83,7 +84,7 @@ fn view_loop(i: ViewIdx, view_nodes: List(#(Section, Node))) -> View { dict.from_list(view_nodes) |> dict.insert( - Section(string.append("view_", string.inspect(i))), + view_index_section(i), Row( content: "", style: Style(dimensions: Percent(width: 100, height: 100)), @@ -92,6 +93,11 @@ fn view_loop(i: ViewIdx, view_nodes: List(#(Section, Node))) -> View { ) } +/// 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( layout: Layout, section: Section, @@ -123,7 +129,7 @@ pub fn update_dimensions(layout: Layout, columns: Int, rows: Int) -> Layout { Layout(..layout, columns:, rows:) } -pub fn set_view(layout: Layout, view_idx: ViewIdx) -> Layout { +pub fn update_current_view(layout: Layout, view_idx: ViewIdx) -> Layout { Layout(..layout, current_view: view_idx) } @@ -145,8 +151,7 @@ pub fn render(layout: Layout) -> Nil { render_loop( view, context, - // TODO extract to function `view_index` - Section(string.append("view_", string.inspect(layout.current_view))), + view_index_section(layout.current_view), buffer, ) |> plot.flush_buffer(layout.columns, layout.rows) diff --git a/src/musicplayer/ui/ui.gleam b/src/musicplayer/ui/ui.gleam index ddd5898..a12fede 100644 --- a/src/musicplayer/ui/ui.gleam +++ b/src/musicplayer/ui/ui.gleam @@ -23,7 +23,31 @@ pub fn new() -> Result(Subject(Control), String) { #( layout.Header, layout.Row( - content: "Foo (1) | Bar (2) | Baz (3)", + content: "Foo <1> | Bar (2)", + 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: 50, + )), + children: [], + ), + ), + ], + [ + #( + layout.Header, + layout.Row( + content: "Foo (1) | Bar <2>", style: layout.Style(dimensions: layout.Percent( width: 100, height: 33, @@ -121,7 +145,7 @@ fn handle_message( actor.stop() } control.SetView(view_idx) -> { - let layout = layout.set_view(state.layout, view_idx) + let layout = layout.update_current_view(state.layout, view_idx) actor.send(state.redraw, layout) actor.continue(State(..state, layout:)) -- 2.52.0