From ed69566f6fb6aed9dff2d52bb8b3ae755f70aab4 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sat, 20 Dec 2025 14:05:25 +0100 Subject: [PATCH] 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) }) -}