From f2cca8a2de58e97ddcb7d2fa8f97b4b4ef920fab Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:09:39 +0200 Subject: [PATCH 01/30] feat(deps): adopt ex_ratatui for cell-based rendering --- mix.exs | 1 + mix.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/mix.exs b/mix.exs index ac773e6..9fb0e5f 100644 --- a/mix.exs +++ b/mix.exs @@ -48,6 +48,7 @@ defmodule NameBadge.MixProject do {:req, "~> 0.5"}, {:dither, "~> 0.1.1"}, {:typst, "~> 0.3"}, + {:ex_ratatui, "~> 0.9"}, {:qr_code, "~> 3.2.0"}, {:tzdata, "~> 1.1"}, {:icalendar, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 0e7ada3..659da1c 100644 --- a/mix.lock +++ b/mix.lock @@ -12,6 +12,7 @@ "eink": {:git, "https://github.com/protolux-electronics/eink.git", "2304b0ce685a1a12e1a2d3a4ea2e257843d6370e", []}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"}, + "ex_ratatui": {:hex, :ex_ratatui, "0.9.0", "4d6ff8706ace76ab5427155e8c52d9f5f355e9c45264ad325db7fd40b89653a6", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e1081aa200152131655e82d4bd93c52becf059666c8312afd4d1bed012f6990"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "extty": {:hex, :extty, "0.4.2", "a180d018cefe6df319859f160b7c94fd7c8119186f9e7b9f18952496dd3ca4d1", [:mix], [], "hexpm", "6d220e5655f293d8e7acb023b3d7ffffb3f294cc2762c3357036ac9459bb6d69"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, From 5cfb4c6d98c9d72f755c8a9edf3db1c0d68c41cc Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:29:59 +0200 Subject: [PATCH 02/30] =?UTF-8?q?feat(ex=5Fratatui):=20add=20embedded=206?= =?UTF-8?q?=C3=978=20ASCII=20bitmap=20font?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/name_badge/ex_ratatui/font.ex | 705 +++++++++++++++++++++++ test/name_badge/ex_ratatui/font_test.exs | 92 +++ 2 files changed, 797 insertions(+) create mode 100644 lib/name_badge/ex_ratatui/font.ex create mode 100644 test/name_badge/ex_ratatui/font_test.exs diff --git a/lib/name_badge/ex_ratatui/font.ex b/lib/name_badge/ex_ratatui/font.ex new file mode 100644 index 0000000..9388ae3 --- /dev/null +++ b/lib/name_badge/ex_ratatui/font.ex @@ -0,0 +1,705 @@ +defmodule NameBadge.ExRatatui.Font do + @moduledoc """ + Embedded 6×8 monospace bitmap font used to rasterise ex_ratatui cell + buffers onto the badge's e-ink display. + + Each cell occupies a fixed `#{6} × #{8}` pixel box: a 5×7 glyph plus + one column of inter-cell spacing on the right and one row of + inter-line spacing at the bottom. The 5×7 ink area uses a + hand-encoded ASCII-art declaration that is parsed to bytes at + compile-time (see `glyph_data/0`). + + ## Glyph format + + `glyph/1` returns an 8-byte binary, one byte per row from top to + bottom. Within each row, the most-significant bit is the leftmost + pixel of the cell. Only the top six bits of each byte are + meaningful; the bottom two are always zero (the rightmost column of + the cell is the inter-cell spacer). Row 7 is always all zeros (the + inter-line spacer). + + iex> bitmap = NameBadge.ExRatatui.Font.glyph(?A) + iex> byte_size(bitmap) + 8 + + ## Coverage + + v1 covers digits 0–9, uppercase A–Z, space, and common ASCII + punctuation — enough for the demo apps that drive the cell-mode + rendering pipeline. Codepoints outside this set fall back to a + visually-distinct hatched box so they are obvious in renderings + rather than silently blank. Lowercase letters and box-drawing glyphs + are deferred to a follow-up. + """ + + @cell_width 6 + @cell_height 8 + + @doc """ + Pixels per cell column. The font is monospace so this is constant. + """ + @spec cell_width() :: pos_integer() + def cell_width(), do: @cell_width + + @doc """ + Pixels per cell row. Constant for this monospace font. + """ + @spec cell_height() :: pos_integer() + def cell_height(), do: @cell_height + + # ASCII-art glyph definitions. Each entry is `{codepoint, art}` where + # `art` is exactly 7 lines of 5 characters; `#` marks an ink pixel and + # `.` marks an empty pixel. The compile-time parser pads each row to + # 6 columns (rightmost column blank for inter-cell spacing) and + # appends a blank 8th row (inter-line spacing). + @glyph_data %{ + ?\s => """ + ..... + ..... + ..... + ..... + ..... + ..... + ..... + """, + ?! => """ + ..#.. + ..#.. + ..#.. + ..#.. + ..#.. + ..... + ..#.. + """, + ?" => """ + .#.#. + .#.#. + .#.#. + ..... + ..... + ..... + ..... + """, + ?# => """ + .#.#. + .#.#. + ##### + .#.#. + ##### + .#.#. + .#.#. + """, + ?$ => """ + ..#.. + .#### + #.#.. + .###. + ..#.# + ####. + ..#.. + """, + ?% => """ + ##... + ##..# + ...#. + ..#.. + .#... + #..## + ...## + """, + ?& => """ + .##.. + #..#. + #..#. + .##.. + #.#.# + #..#. + .##.# + """, + ?' => """ + ..#.. + ..#.. + ..#.. + ..... + ..... + ..... + ..... + """, + ?( => """ + ...#. + ..#.. + .#... + .#... + .#... + ..#.. + ...#. + """, + ?) => """ + .#... + ..#.. + ...#. + ...#. + ...#. + ..#.. + .#... + """, + ?* => """ + ..... + .#.#. + .###. + ##### + .###. + .#.#. + ..... + """, + ?+ => """ + ..... + ..#.. + ..#.. + ##### + ..#.. + ..#.. + ..... + """, + ?, => """ + ..... + ..... + ..... + ..... + ..... + ..#.. + .#... + """, + ?- => """ + ..... + ..... + ..... + ##### + ..... + ..... + ..... + """, + ?. => """ + ..... + ..... + ..... + ..... + ..... + ..... + ..#.. + """, + ?/ => """ + ....# + ...#. + ..#.. + ..#.. + .#... + #.... + #.... + """, + ?0 => """ + .###. + #...# + #..## + #.#.# + ##..# + #...# + .###. + """, + ?1 => """ + ..#.. + .##.. + ..#.. + ..#.. + ..#.. + ..#.. + .###. + """, + ?2 => """ + .###. + #...# + ....# + ...#. + ..#.. + .#... + ##### + """, + ?3 => """ + .###. + #...# + ....# + ..##. + ....# + #...# + .###. + """, + ?4 => """ + ...#. + ..##. + .#.#. + #..#. + ##### + ...#. + ...#. + """, + ?5 => """ + ##### + #.... + ####. + ....# + ....# + #...# + .###. + """, + ?6 => """ + .###. + #...# + #.... + ####. + #...# + #...# + .###. + """, + ?7 => """ + ##### + ....# + ...#. + ..#.. + .#... + .#... + .#... + """, + ?8 => """ + .###. + #...# + #...# + .###. + #...# + #...# + .###. + """, + ?9 => """ + .###. + #...# + #...# + .#### + ....# + #...# + .###. + """, + ?: => """ + ..... + ..... + ..#.. + ..... + ..#.. + ..... + ..... + """, + ?; => """ + ..... + ..... + ..#.. + ..... + ..#.. + ..#.. + .#... + """, + ?< => """ + ....# + ...#. + ..#.. + .#... + ..#.. + ...#. + ....# + """, + ?= => """ + ..... + ..... + ##### + ..... + ##### + ..... + ..... + """, + ?> => """ + #.... + .#... + ..#.. + ...#. + ..#.. + .#... + #.... + """, + ?? => """ + .###. + #...# + ....# + ..##. + ..#.. + ..... + ..#.. + """, + ?@ => """ + .###. + #...# + #.### + #.#.# + #.### + #.... + .###. + """, + ?A => """ + .###. + #...# + #...# + ##### + #...# + #...# + #...# + """, + ?B => """ + ####. + #...# + #...# + ####. + #...# + #...# + ####. + """, + ?C => """ + .###. + #...# + #.... + #.... + #.... + #...# + .###. + """, + ?D => """ + ####. + #...# + #...# + #...# + #...# + #...# + ####. + """, + ?E => """ + ##### + #.... + #.... + ####. + #.... + #.... + ##### + """, + ?F => """ + ##### + #.... + #.... + ####. + #.... + #.... + #.... + """, + ?G => """ + .###. + #...# + #.... + #.### + #...# + #...# + .###. + """, + ?H => """ + #...# + #...# + #...# + ##### + #...# + #...# + #...# + """, + ?I => """ + .###. + ..#.. + ..#.. + ..#.. + ..#.. + ..#.. + .###. + """, + ?J => """ + ..### + ...#. + ...#. + ...#. + ...#. + #..#. + .##.. + """, + ?K => """ + #...# + #..#. + #.#.. + ##... + #.#.. + #..#. + #...# + """, + ?L => """ + #.... + #.... + #.... + #.... + #.... + #.... + ##### + """, + ?M => """ + #...# + ##.## + #.#.# + #.#.# + #...# + #...# + #...# + """, + ?N => """ + #...# + #...# + ##..# + #.#.# + #..## + #...# + #...# + """, + ?O => """ + .###. + #...# + #...# + #...# + #...# + #...# + .###. + """, + ?P => """ + ####. + #...# + #...# + ####. + #.... + #.... + #.... + """, + ?Q => """ + .###. + #...# + #...# + #...# + #.#.# + #..#. + .##.# + """, + ?R => """ + ####. + #...# + #...# + ####. + #.#.. + #..#. + #...# + """, + ?S => """ + .###. + #...# + #.... + .###. + ....# + #...# + .###. + """, + ?T => """ + ##### + ..#.. + ..#.. + ..#.. + ..#.. + ..#.. + ..#.. + """, + ?U => """ + #...# + #...# + #...# + #...# + #...# + #...# + .###. + """, + ?V => """ + #...# + #...# + #...# + #...# + #...# + .#.#. + ..#.. + """, + ?W => """ + #...# + #...# + #...# + #.#.# + #.#.# + #.#.# + .#.#. + """, + ?X => """ + #...# + #...# + .#.#. + ..#.. + .#.#. + #...# + #...# + """, + ?Y => """ + #...# + #...# + #...# + .#.#. + ..#.. + ..#.. + ..#.. + """, + ?Z => """ + ##### + ....# + ...#. + ..#.. + .#... + #.... + ##### + """, + ?[ => """ + .###. + .#... + .#... + .#... + .#... + .#... + .###. + """, + ?\\ => """ + #.... + #.... + .#... + ..#.. + ..#.. + ...#. + ....# + """, + ?] => """ + .###. + ...#. + ...#. + ...#. + ...#. + ...#. + .###. + """, + ?^ => """ + ..#.. + .#.#. + #...# + ..... + ..... + ..... + ..... + """, + ?_ => """ + ..... + ..... + ..... + ..... + ..... + ..... + ##### + """ + } + + # Compile-time conversion of each ASCII-art block to an 8-byte + # binary. Done inline (no helper-function calls) because the module + # itself isn't fully defined while its attributes are being + # evaluated. + @glyphs Map.new(@glyph_data, fn {codepoint, art} -> + rows = + art + |> String.split("\n", trim: true) + |> Enum.map(fn line -> + chars = String.graphemes(line) + 5 = length(chars) + + [b5, b4, b3, b2, b1, b0] = + Enum.map(chars ++ ["."], fn + "#" -> 1 + "." -> 0 + end) + + <> + end) + + 7 = length(rows) + bitmap = IO.iodata_to_binary([rows, <<0>>]) + 8 = byte_size(bitmap) + {codepoint, bitmap} + end) + + @missing_glyph << + 0b10101010, + 0b01010100, + 0b10101010, + 0b01010100, + 0b10101010, + 0b01010100, + 0b10101010, + 0b00000000 + >> + + @doc """ + Returns the 8-byte bitmap for the glyph at `codepoint`. + + Each byte encodes one row from top to bottom. Within a row the + most-significant bit is the leftmost pixel of the cell; bits 1 and 0 + are always zero (rightmost column is the inter-cell spacer). Row 7 + is always zero (the inter-line spacer). + + Codepoints with no encoded glyph return a hatched placeholder so + they render as a visibly-distinct "missing" cell rather than + silently blank. + """ + @spec glyph(integer()) :: <<_::64>> + def glyph(codepoint) when is_integer(codepoint) do + Map.get(@glyphs, codepoint, @missing_glyph) + end + + @doc """ + Returns `true` if `codepoint` has a hand-encoded glyph in the font. + Useful for callers that want to substitute their own placeholder for + unsupported characters. + """ + @spec has_glyph?(integer()) :: boolean() + def has_glyph?(codepoint) when is_integer(codepoint) do + Map.has_key?(@glyphs, codepoint) + end + + @doc """ + Returns the list of codepoints with hand-encoded glyphs, sorted + ascending. Primarily for tests and tooling. + """ + @spec codepoints() :: [integer()] + def codepoints(), do: @glyphs |> Map.keys() |> Enum.sort() +end diff --git a/test/name_badge/ex_ratatui/font_test.exs b/test/name_badge/ex_ratatui/font_test.exs new file mode 100644 index 0000000..fc081dc --- /dev/null +++ b/test/name_badge/ex_ratatui/font_test.exs @@ -0,0 +1,92 @@ +defmodule NameBadge.ExRatatui.FontTest do + use ExUnit.Case, async: true + + alias NameBadge.ExRatatui.Font + + describe "cell dimensions" do + test "are 6×8" do + assert Font.cell_width() == 6 + assert Font.cell_height() == 8 + end + end + + describe "glyph/1" do + test "returns 8 bytes for an encoded codepoint" do + assert <<_::64>> = Font.glyph(?A) + assert byte_size(Font.glyph(?A)) == 8 + end + + test "returns 8 bytes for an unencoded codepoint (placeholder)" do + # Heart emoji has no encoded glyph; still must be safe to + # rasterise. + assert byte_size(Font.glyph(0x2764)) == 8 + end + + test "row 7 is always the inter-line spacer (zero)" do + for codepoint <- Font.codepoints() do + <<_top::7-bytes, last>> = Font.glyph(codepoint) + assert last == 0, "row 7 of glyph #{inspect(codepoint)} must be blank, got #{last}" + end + end + + test "bottom two bits of every row are always zero (rightmost column blank)" do + for codepoint <- Font.codepoints(), + <> <- :binary.bin_to_list(Font.glyph(codepoint)) |> Enum.map(&<<&1>>) do + assert Bitwise.band(row, 0b11) == 0, + "glyph #{inspect(codepoint)} has ink in the right-spacer column: row=#{row}" + end + end + + test "?A renders the canonical capital-A bitmap" do + # .###. → 0b01110000 + # #...# → 0b10001000 + # #...# → 0b10001000 + # ##### → 0b11111000 + # #...# → 0b10001000 + # #...# → 0b10001000 + # #...# → 0b10001000 + # blank → 0b00000000 + assert Font.glyph(?A) == + <<0b01110000, 0b10001000, 0b10001000, 0b11111000, 0b10001000, 0b10001000, + 0b10001000, 0b00000000>> + end + + test "?\\s (space) is entirely blank" do + assert Font.glyph(?\s) == <<0, 0, 0, 0, 0, 0, 0, 0>> + end + + test "?0 is encoded" do + assert Font.has_glyph?(?0) + refute Font.glyph(?0) == <<0, 0, 0, 0, 0, 0, 0, 0>> + end + end + + describe "has_glyph?/1" do + test "is true for digits, uppercase, and common punctuation" do + for cp <- Enum.concat([?0..?9, ?A..?Z, [?\s, ?., ?,, ?:, ?+, ?-, ??, ?!]]) do + assert Font.has_glyph?(cp), "expected glyph for #{[cp]}" + end + end + + test "is false for codepoints we haven't encoded yet (lowercase, emoji)" do + refute Font.has_glyph?(?a) + refute Font.has_glyph?(0x2764) + end + end + + describe "codepoints/0" do + test "returns at least the Counter demo's character set" do + needed = Enum.concat([?0..?9, ?A..?Z, [?\s, ?:, ?+, ?-]]) + have = MapSet.new(Font.codepoints()) + + for cp <- needed do + assert cp in have, "Counter demo needs #{[cp]} (#{cp}) but it isn't encoded" + end + end + + test "is sorted" do + cps = Font.codepoints() + assert cps == Enum.sort(cps) + end + end +end From f730f09610fdd95d2ab25bd09c5bdaabc382c85d Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:37:36 +0200 Subject: [PATCH 03/30] feat(ex_ratatui): rasterise CellSession buffers to grayscale PNG --- lib/name_badge/ex_ratatui/raster.ex | 169 +++++++++++++++++++++ test/name_badge/ex_ratatui/raster_test.exs | 142 +++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 lib/name_badge/ex_ratatui/raster.ex create mode 100644 test/name_badge/ex_ratatui/raster_test.exs diff --git a/lib/name_badge/ex_ratatui/raster.ex b/lib/name_badge/ex_ratatui/raster.ex new file mode 100644 index 0000000..883a081 --- /dev/null +++ b/lib/name_badge/ex_ratatui/raster.ex @@ -0,0 +1,169 @@ +defmodule NameBadge.ExRatatui.Raster do + @moduledoc """ + Rasterises an `ExRatatui.CellSession` cell buffer into a 400×300 + grayscale image suitable for `NameBadge.Display.render_png/2`. + + Holds an internal cell map keyed by `{col, row}`. Snapshots replace + the map outright; diffs merge into it so successive renders only pay + for what actually changed. + + ## Pipeline + + Raster.new() + |> Raster.put_snapshot(snapshot) # initial paint + |> Raster.apply_diff(diff) # streaming update + |> Raster.to_png() # 400×300 grayscale PNG + + The resulting PNG is fed unchanged to `NameBadge.Display.render_png/2`, + which already handles grayscale → 1bpp threshold → SPI blit. + + ## Cell to pixel layout + + The font's `#{6} × #{8}` cell, when tiled across a 400×300 canvas, + fits a #{div(400, 6)} × #{div(300, 8)} grid (400 / 6 = 66 with 4 + unused pixels on the right; 300 / 8 = 37 with 4 unused pixels at the + bottom). The unused strip stays paper-white. Cell sessions should be + constructed at this grid size — see `grid_size/0`. + + ## Style support (v1) + + Glyphs render as ink-on-paper regardless of `fg`/`bg`. Modifiers + (bold, italic, underlined, reversed, …) and the `:skip` flag are + ignored except `:skip` cells render as paper. Reverse-video and + bold-as-double-strike will land in a follow-up; they aren't needed + for the v1 demo apps. + """ + + import Bitwise + + alias ExRatatui.CellSession.{Cell, Diff, Snapshot} + alias NameBadge.ExRatatui.Font + + @display_width 400 + @display_height 300 + @paper 255 + @ink 0 + + @cell_w Font.cell_width() + @cell_h Font.cell_height() + @grid_cols div(@display_width, @cell_w) + @grid_rows div(@display_height, @cell_h) + + defstruct cells: %{} + + @type t :: %__MODULE__{cells: %{{non_neg_integer(), non_neg_integer()} => Cell.t()}} + + @doc """ + Returns a fresh rasteriser with no cells (the canvas reads as + uniform paper). + """ + @spec new() :: t() + def new(), do: %__MODULE__{} + + @doc """ + Cell-grid dimensions that fit on the badge display, as + `{cols, rows}`. Use these when constructing the `ExRatatui.CellSession`. + """ + @spec grid_size() :: {pos_integer(), pos_integer()} + def grid_size(), do: {@grid_cols, @grid_rows} + + @doc """ + Pixel dimensions of the rasterised image, as `{width, height}`. + """ + @spec display_size() :: {pos_integer(), pos_integer()} + def display_size(), do: {@display_width, @display_height} + + @doc """ + Replaces the rasteriser's cell map with the snapshot's cells. + + Use after an initial `take_cells/1`, after a resize, or whenever the + caller wants to discard accumulated diff state. + """ + @spec put_snapshot(t(), Snapshot.t()) :: t() + def put_snapshot(%__MODULE__{} = r, %Snapshot{cells: cells}) do + %{r | cells: index(cells)} + end + + @doc """ + Merges a diff's ops into the rasteriser's cell map. + + Cells not mentioned in the diff retain their prior content. The + caller is responsible for handling the "full payload after resize" + case (`length(ops) == width * height`); from the rasteriser's point + of view that's just a diff that happens to cover everything. + """ + @spec apply_diff(t(), Diff.t()) :: t() + def apply_diff(%__MODULE__{cells: existing} = r, %Diff{ops: ops}) do + %{r | cells: Map.merge(existing, index(ops))} + end + + @doc """ + Renders the current cell map to a 400×300 grayscale PNG, ready to + hand to `NameBadge.Display.render_png/2`. + """ + @spec to_png(t()) :: binary() + def to_png(%__MODULE__{} = r) do + r + |> to_grayscale() + |> Dither.from_raw!(@display_width, @display_height) + |> Dither.encode!() + end + + @doc """ + Renders the current cell map to a flat 400×300 row-major grayscale + binary (one byte per pixel, 0 = ink, 255 = paper). Mostly useful for + tests; production callers want `to_png/1`. + """ + @spec to_grayscale(t()) :: binary() + def to_grayscale(%__MODULE__{cells: cells}) do + Enum.reduce(0..(@display_height - 1), [], fn y, acc -> + cell_row = div(y, @cell_h) + sub_y = rem(y, @cell_h) + + pixel_row = + Enum.map(0..(@grid_cols - 1), fn cx -> + cell_pixel_row(Map.get(cells, {cx, cell_row}), sub_y) + end) + + [acc, pixel_row, paper_padding(:right)] + end) + |> IO.iodata_to_binary() + end + + @paper_cell_row :binary.copy(<<@paper>>, @cell_w) + @paper_right_padding :binary.copy(<<@paper>>, @display_width - @grid_cols * @cell_w) + + defp paper_padding(:right), do: @paper_right_padding + + defp cell_pixel_row(nil, _sub_y), do: @paper_cell_row + defp cell_pixel_row(%Cell{skip: true}, _sub_y), do: @paper_cell_row + + defp cell_pixel_row(%Cell{symbol: symbol}, sub_y) do + byte = + symbol + |> codepoint_of() + |> Font.glyph() + |> :binary.at(sub_y) + + Enum.map(0..(@cell_w - 1), fn i -> + case byte >>> (7 - i) &&& 1 do + 1 -> @ink + 0 -> @paper + end + end) + |> :binary.list_to_bin() + end + + defp codepoint_of(""), do: ?\s + + defp codepoint_of(symbol) when is_binary(symbol) do + case String.to_charlist(symbol) do + [cp | _] -> cp + [] -> ?\s + end + end + + defp index(cells) do + Map.new(cells, fn %Cell{col: c, row: r} = cell -> {{c, r}, cell} end) + end +end diff --git a/test/name_badge/ex_ratatui/raster_test.exs b/test/name_badge/ex_ratatui/raster_test.exs new file mode 100644 index 0000000..4e65820 --- /dev/null +++ b/test/name_badge/ex_ratatui/raster_test.exs @@ -0,0 +1,142 @@ +defmodule NameBadge.ExRatatui.RasterTest do + use ExUnit.Case, async: true + + alias ExRatatui.CellSession.{Cell, Diff, Snapshot} + alias NameBadge.ExRatatui.Raster + + describe "grid_size/0 and display_size/0" do + test "match the badge display" do + assert Raster.display_size() == {400, 300} + # 400 / 6 = 66 (4 px right margin); 300 / 8 = 37 (4 px bottom margin). + assert Raster.grid_size() == {66, 37} + end + end + + describe "to_grayscale/1" do + test "an empty rasteriser produces a uniform paper canvas" do + bin = Raster.new() |> Raster.to_grayscale() + + assert byte_size(bin) == 400 * 300 + assert :binary.copy(<<255>>, 400 * 300) == bin + end + + test "a snapshot with one ink cell paints exactly that cell's glyph" do + raster = + Raster.new() + |> Raster.put_snapshot(snapshot([cell(0, 0, "A")])) + + bin = Raster.to_grayscale(raster) + assert byte_size(bin) == 400 * 300 + + # The 'A' glyph's first row is `.###.` → pixels (1,0), (2,0), (3,0) + # are ink; (0,0), (4,0), (5,0) are paper. Row stride is 400 bytes. + assert byte_at(bin, 0, 0) == 255 + assert byte_at(bin, 1, 0) == 0 + assert byte_at(bin, 2, 0) == 0 + assert byte_at(bin, 3, 0) == 0 + assert byte_at(bin, 4, 0) == 255 + assert byte_at(bin, 5, 0) == 255 + + # Row 7 is the inter-line spacer — must be paper across the whole cell. + for x <- 0..5 do + assert byte_at(bin, x, 7) == 255 + end + + # Cell at (1, 0) wasn't painted — must be entirely paper. + for x <- 6..11, y <- 0..7 do + assert byte_at(bin, x, y) == 255, + "expected paper at (#{x}, #{y}), got #{byte_at(bin, x, y)}" + end + end + + test "skip cells render as paper" do + raster = + Raster.new() + |> Raster.put_snapshot(snapshot([%{cell(0, 0, "A") | skip: true}])) + + bin = Raster.to_grayscale(raster) + + for x <- 0..5, y <- 0..7 do + assert byte_at(bin, x, y) == 255 + end + end + + test "right and bottom margins are paper (canvas is wider than the grid)" do + # Fill row 0 with 'A' across every cell column. The 4 unused + # right-edge pixels (cols 396..399 in pixel space) must stay paper. + cells = for col <- 0..(elem(Raster.grid_size(), 0) - 1), do: cell(col, 0, "A") + + bin = + Raster.new() + |> Raster.put_snapshot(snapshot(cells)) + |> Raster.to_grayscale() + + for x <- 396..399, y <- 0..7 do + assert byte_at(bin, x, y) == 255 + end + + # Bottom 4 px (rows 296..299) are below the cell grid. + for x <- 0..399, y <- 296..299 do + assert byte_at(bin, x, y) == 255 + end + end + end + + describe "apply_diff/2" do + test "merges into the existing cell map without disturbing other cells" do + base = + Raster.new() + |> Raster.put_snapshot(snapshot([cell(0, 0, "A"), cell(1, 0, "B")])) + + updated = Raster.apply_diff(base, diff([cell(0, 0, "C")])) + bin = Raster.to_grayscale(updated) + + # Cell (1, 0) at pixel (6..11, 0..7) still shows 'B'. 'B's top + # row is `####.` so pixels (6..9, 0) are ink and (10, 0) is paper. + assert byte_at(bin, 6, 0) == 0 + assert byte_at(bin, 7, 0) == 0 + assert byte_at(bin, 8, 0) == 0 + assert byte_at(bin, 9, 0) == 0 + assert byte_at(bin, 10, 0) == 255 + + # Cell (0, 0) was overwritten with 'C': top row `.###.`, so pixels + # (1..3, 0) are ink and (0, 0) and (4, 0) are paper. + assert byte_at(bin, 0, 0) == 255 + assert byte_at(bin, 1, 0) == 0 + assert byte_at(bin, 4, 0) == 255 + end + end + + describe "to_png/1" do + test "produces a non-empty PNG that decodes back to the same dimensions" do + png = + Raster.new() + |> Raster.put_snapshot(snapshot([cell(0, 0, "A")])) + |> Raster.to_png() + + assert <<137, 80, 78, 71, 13, 10, 26, 10, _rest::binary>> = png + + ref = Dither.decode!(png) + raw = ref |> Dither.grayscale!() |> Dither.to_raw!() + assert byte_size(raw) == 400 * 300 + end + end + + defp cell(col, row, symbol) do + %Cell{col: col, row: row, symbol: symbol, fg: :reset, bg: :reset, modifiers: [], skip: false} + end + + defp snapshot(cells) do + {cols, rows} = Raster.grid_size() + %Snapshot{width: cols, height: rows, cells: cells} + end + + defp diff(ops) do + {cols, rows} = Raster.grid_size() + %Diff{width: cols, height: rows, ops: ops} + end + + defp byte_at(canvas, x, y) when x in 0..399 and y in 0..299 do + :binary.at(canvas, y * 400 + x) + end +end From 8da5ace9e8b36bfcdc41ff8eee8b5d7f8e8c05cb Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:43:05 +0200 Subject: [PATCH 04/30] feat(screen): add generic Screen.ExRatatui adapter --- lib/name_badge/battery.ex | 9 +- lib/name_badge/mocks/battery_mock.ex | 9 +- lib/name_badge/screen/ex_ratatui.ex | 127 +++++++++++++++++++ test/name_badge/screen/ex_ratatui_test.exs | 136 +++++++++++++++++++++ 4 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 lib/name_badge/screen/ex_ratatui.ex create mode 100644 test/name_badge/screen/ex_ratatui_test.exs diff --git a/lib/name_badge/battery.ex b/lib/name_badge/battery.ex index 6ad18d3..9a856a1 100644 --- a/lib/name_badge/battery.ex +++ b/lib/name_badge/battery.ex @@ -18,10 +18,11 @@ defmodule NameBadge.Battery do min_voltage = 3.0 max_voltage = 4.2 - percentage = ((v - min_voltage) / (max_voltage - min_voltage) * 100) - |> max(0) - |> min(100) - |> round() + percentage = + ((v - min_voltage) / (max_voltage - min_voltage) * 100) + |> max(0) + |> min(100) + |> round() percentage end diff --git a/lib/name_badge/mocks/battery_mock.ex b/lib/name_badge/mocks/battery_mock.ex index 741ff5c..fa74f9a 100644 --- a/lib/name_badge/mocks/battery_mock.ex +++ b/lib/name_badge/mocks/battery_mock.ex @@ -21,10 +21,11 @@ defmodule NameBadge.BatteryMock do min_voltage = 3.0 max_voltage = 4.2 - percentage = ((v - min_voltage) / (max_voltage - min_voltage) * 100) - |> max(0) - |> min(100) - |> round() + percentage = + ((v - min_voltage) / (max_voltage - min_voltage) * 100) + |> max(0) + |> min(100) + |> round() percentage end diff --git a/lib/name_badge/screen/ex_ratatui.ex b/lib/name_badge/screen/ex_ratatui.ex new file mode 100644 index 0000000..bd7cbee --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -0,0 +1,127 @@ +defmodule NameBadge.Screen.ExRatatui do + @moduledoc """ + `NameBadge.Screen` adapter that hosts an `ExRatatui.App`, ferries + badge button events into it, and rasterises its rendered cell buffer + through the existing `NameBadge.Display.render_png/2` pipeline. + + ## Wiring + + ExRatatui.App + │ + ▼ + ExRatatui.Server (started in `mount/2`) + │ cell_writer_fn + ▼ + send(screen_pid, {:ex_ratatui_diff, %CellSession.Diff{}}) + │ + ▼ + handle_info/2 → Raster.apply_diff/2 → Raster.to_png/1 + │ + ▼ + assign(screen, :png, png) + │ + ▼ + base NameBadge.Screen sees assigns changed → render/1 returns + the PNG → Display.render_png(png, refresh_type: :partial) + + ## Mount args + + mount: [ + app: MyTui, # required, implements ExRatatui.App + app_opts: [], # optional, forwarded to the App's mount/1 + key_map: %{...} # optional, overrides the button mapping below + ] + + ## Default key map + + | Badge input | ExRatatui.Event.Key | + | ----------------------- | ------------------- | + | A (single press) | `code: "up"` | + | A (long press) | `code: "home"` | + | B (single press) | `code: "down"` | + | B (long press) | (intercepted by `NameBadge.Screen` for navigate `:back`) | + + Apps that need a different mapping (e.g. Snake-as-TUI wanting + left/right) pass a `:key_map` keyed by `{:button_1 | :button_2, + :single_press | :long_press}` whose values are + `t:ExRatatui.Event.Key.t/0`. + """ + + use NameBadge.Screen + + alias ExRatatui.CellSession + alias ExRatatui.Event.Key + alias NameBadge.ExRatatui.Raster + + @default_key_map %{ + {:button_1, :single_press} => %Key{code: "up", kind: "press", modifiers: []}, + {:button_1, :long_press} => %Key{code: "home", kind: "press", modifiers: []}, + {:button_2, :single_press} => %Key{code: "down", kind: "press", modifiers: []} + } + + @impl NameBadge.Screen + def mount(args, screen) do + app_mod = Keyword.fetch!(args, :app) + app_opts = Keyword.get(args, :app_opts, []) + key_map = Keyword.get(args, :key_map, @default_key_map) + + {cols, rows} = Raster.grid_size() + session = CellSession.new(cols, rows) + + screen_pid = self() + cell_writer = fn diff -> send(screen_pid, {:ex_ratatui_diff, diff}) end + + server_opts = + [ + mod: app_mod, + name: nil, + transport: {:cell_session, session, cell_writer} + ] ++ app_opts + + {:ok, server} = ExRatatui.Transport.start_server(server_opts) + + {:ok, + screen + |> assign(:server, server) + |> assign(:session, session) + |> assign(:key_map, key_map) + |> assign(:raster, Raster.new()) + |> assign(:png, blank_png())} + end + + @impl NameBadge.Screen + def render(assigns), do: assigns.png + + @impl NameBadge.Screen + def handle_button(button, press_type, screen) do + case Map.fetch(screen.assigns.key_map, {button, press_type}) do + {:ok, %Key{} = event} -> + send(screen.assigns.server, {:ex_ratatui_event, event}) + {:noreply, screen} + + :error -> + {:noreply, screen} + end + end + + @impl NameBadge.Screen + def handle_info({:ex_ratatui_diff, diff}, screen) do + raster = Raster.apply_diff(screen.assigns.raster, diff) + png = Raster.to_png(raster) + + {:noreply, + screen + |> assign(:raster, raster) + |> assign(:png, png)} + end + + def handle_info(_other, screen), do: {:noreply, screen} + + @impl NameBadge.Screen + def terminate(_reason, screen) do + if session = screen.assigns[:session], do: CellSession.close(session) + :ok + end + + defp blank_png(), do: Raster.new() |> Raster.to_png() +end diff --git a/test/name_badge/screen/ex_ratatui_test.exs b/test/name_badge/screen/ex_ratatui_test.exs new file mode 100644 index 0000000..d0e652d --- /dev/null +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -0,0 +1,136 @@ +defmodule NameBadge.Screen.ExRatatuiTest do + use ExUnit.Case, async: true + + alias ExRatatui.CellSession.{Cell, Diff} + alias ExRatatui.Event.Key + alias NameBadge.ExRatatui.Raster + alias NameBadge.Screen + alias NameBadge.Screen.ExRatatui, as: Adapter + + describe "handle_button/3" do + test "forwards single A as Key{code: \"up\"} to the server" do + screen = screen_with_server() + + assert {:noreply, _} = Adapter.handle_button(:button_1, :single_press, screen) + + assert_receive {:ex_ratatui_event, %Key{code: "up", kind: "press", modifiers: []}} + end + + test "forwards long A as Key{code: \"home\"}" do + screen = screen_with_server() + + assert {:noreply, _} = Adapter.handle_button(:button_1, :long_press, screen) + + assert_receive {:ex_ratatui_event, %Key{code: "home"}} + end + + test "forwards single B as Key{code: \"down\"}" do + screen = screen_with_server() + + assert {:noreply, _} = Adapter.handle_button(:button_2, :single_press, screen) + + assert_receive {:ex_ratatui_event, %Key{code: "down"}} + end + + test "ignores button combinations that aren't in the key map" do + # Long-press B is reserved by NameBadge.Screen for navigate :back — + # the adapter never sees it. But guard against any future button + # the framework forwards through that we haven't mapped yet. + screen = screen_with_server() + + assert {:noreply, ^screen} = Adapter.handle_button(:button_2, :long_press, screen) + refute_received {:ex_ratatui_event, _} + end + + test "honours a custom key map passed via mount args" do + screen = + screen_with_server(%{ + {:button_1, :single_press} => %Key{code: "left", kind: "press", modifiers: []}, + {:button_2, :single_press} => %Key{code: "right", kind: "press", modifiers: []} + }) + + Adapter.handle_button(:button_1, :single_press, screen) + Adapter.handle_button(:button_2, :single_press, screen) + + assert_receive {:ex_ratatui_event, %Key{code: "left"}} + assert_receive {:ex_ratatui_event, %Key{code: "right"}} + end + end + + describe "handle_info/2 on cell diffs" do + test "merges the diff into the raster and re-encodes the PNG" do + screen = %Screen{ + module: Adapter, + assigns: %{raster: Raster.new(), png: <<>>} + } + + diff = %Diff{ + width: 66, + height: 37, + ops: [ + %Cell{ + col: 0, + row: 0, + symbol: "A", + fg: :reset, + bg: :reset, + modifiers: [], + skip: false + } + ] + } + + assert {:noreply, updated} = Adapter.handle_info({:ex_ratatui_diff, diff}, screen) + + # PNG header. + assert <<137, 80, 78, 71, 13, 10, 26, 10, _::binary>> = updated.assigns.png + + # The raster carries one cell now. + assert map_size(updated.assigns.raster.cells) == 1 + assert {%Cell{symbol: "A"}, _} = Map.pop!(updated.assigns.raster.cells, {0, 0}) + end + + test "ignores unrelated info messages" do + screen = %Screen{module: Adapter, assigns: %{}} + + assert {:noreply, ^screen} = Adapter.handle_info(:something_else, screen) + assert {:noreply, ^screen} = Adapter.handle_info({:unrelated, 1, 2}, screen) + end + end + + describe "terminate/2" do + test "is safe to call without a session in assigns" do + screen = %Screen{module: Adapter, assigns: %{}} + + assert :ok = Adapter.terminate(:normal, screen) + end + + test "closes a CellSession in assigns" do + session = ExRatatui.CellSession.new(10, 5) + screen = %Screen{module: Adapter, assigns: %{session: session}} + + assert :ok = Adapter.terminate(:normal, screen) + + # Closed sessions return {:error, _} on draw — proves close took. + assert {:error, _} = ExRatatui.CellSession.draw(session, []) + end + end + + # Builds a Screen struct whose `:server` assign is the test process + # itself, so the adapter's `send(server, ...)` lands in this test's + # mailbox where `assert_receive` can pick it up. + defp screen_with_server(key_map \\ default_key_map()) do + %Screen{ + module: Adapter, + assigns: %{server: self(), key_map: key_map} + } + end + + defp default_key_map() do + %{ + {:button_1, :single_press} => %Key{code: "up", kind: "press", modifiers: []}, + {:button_1, :long_press} => %Key{code: "home", kind: "press", modifiers: []}, + {:button_2, :single_press} => %Key{code: "down", kind: "press", modifiers: []} + } + end +end From e150cdf0fc3d0629d57741e43ac318393a020a91 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:45:35 +0200 Subject: [PATCH 05/30] feat(screen): add Counter demo ExRatatui app --- lib/name_badge/screen/counter.ex | 10 +++ lib/name_badge/screen/ex_ratatui/counter.ex | 52 +++++++++++++ test/name_badge/screen/counter_test.exs | 39 ++++++++++ .../screen/ex_ratatui/counter_test.exs | 73 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 lib/name_badge/screen/counter.ex create mode 100644 lib/name_badge/screen/ex_ratatui/counter.ex create mode 100644 test/name_badge/screen/counter_test.exs create mode 100644 test/name_badge/screen/ex_ratatui/counter_test.exs diff --git a/lib/name_badge/screen/counter.ex b/lib/name_badge/screen/counter.ex new file mode 100644 index 0000000..3dba4c4 --- /dev/null +++ b/lib/name_badge/screen/counter.ex @@ -0,0 +1,10 @@ +defmodule NameBadge.Screen.Counter do + @moduledoc """ + Menu-facing wrapper around `NameBadge.Screen.ExRatatui.Counter` — + hosts the Counter `ExRatatui.App` through the + `NameBadge.Screen.ExRatatui` adapter, using the adapter's default + A/B/A-long key map. + """ + + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Counter +end diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex new file mode 100644 index 0000000..6aa072a --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -0,0 +1,52 @@ +defmodule NameBadge.Screen.ExRatatui.Counter do + @moduledoc """ + A two-button TUI counter — the first end-to-end demo of the + `NameBadge.Screen.ExRatatui` adapter. + + ## Controls + + | Key (TUI) | Badge button | Action | + | -------------------- | ------------------ | ------------ | + | `up` | A (single press) | Increment | + | `down` | B (single press) | Decrement | + | `home` | A (long press) | Reset to 0 | + | (handled by `Screen`)| B (long press) | Back to menu | + + The mapping from badge button to TUI key code is owned by + `NameBadge.Screen.ExRatatui`'s default key map. Switching this app + to e.g. left/right is a matter of passing a different `:key_map` in + the screen's mount args, not editing this module. + """ + + @behaviour ExRatatui.App + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Widgets.Paragraph + + @impl ExRatatui.App + def mount(_opts), do: {:ok, %{count: 0}} + + @impl ExRatatui.App + def render(state, frame) do + [ + {%Paragraph{text: "COUNTER", alignment: :center}, + %Rect{x: 0, y: 1, width: frame.width, height: 1}}, + {%Paragraph{text: "COUNT: #{state.count}", alignment: :center}, + %Rect{x: 0, y: div(frame.height, 2), width: frame.width, height: 1}}, + {%Paragraph{text: "A: +1 A LONG: RESET", alignment: :center}, + %Rect{x: 0, y: frame.height - 3, width: frame.width, height: 1}}, + {%Paragraph{text: "B: -1 B LONG: BACK", alignment: :center}, + %Rect{x: 0, y: frame.height - 2, width: frame.width, height: 1}} + ] + end + + @impl ExRatatui.App + def handle_event(%Key{code: "up"}, state), do: {:noreply, %{state | count: state.count + 1}} + def handle_event(%Key{code: "down"}, state), do: {:noreply, %{state | count: state.count - 1}} + def handle_event(%Key{code: "home"}, state), do: {:noreply, %{state | count: 0}} + def handle_event(_event, state), do: {:noreply, state} + + @impl ExRatatui.App + def handle_info(_message, state), do: {:noreply, state} +end diff --git a/test/name_badge/screen/counter_test.exs b/test/name_badge/screen/counter_test.exs new file mode 100644 index 0000000..556415b --- /dev/null +++ b/test/name_badge/screen/counter_test.exs @@ -0,0 +1,39 @@ +defmodule NameBadge.Screen.CounterTest do + use ExUnit.Case, async: true + + alias ExRatatui.Event.Key + alias NameBadge.Screen + alias NameBadge.Screen.Counter + + describe "render/1" do + test "delegates to the adapter (returns the cached PNG from assigns)" do + png = <<137, 80, 78, 71, 13, 10, 26, 10, "fake">> + assert Counter.render(%{png: png}) == png + end + end + + describe "handle_button/3" do + test "delegates to the adapter and forwards to the configured key map" do + screen = %Screen{ + module: Counter, + assigns: %{ + server: self(), + key_map: %{ + {:button_1, :single_press} => %Key{code: "up", kind: "press", modifiers: []} + } + } + } + + assert {:noreply, _} = Counter.handle_button(:button_1, :single_press, screen) + assert_receive {:ex_ratatui_event, %Key{code: "up"}} + end + end + + describe "handle_info/2" do + test "delegates unrelated messages to the adapter (which ignores them)" do + screen = %Screen{module: Counter, assigns: %{}} + + assert {:noreply, ^screen} = Counter.handle_info(:tick, screen) + end + end +end diff --git a/test/name_badge/screen/ex_ratatui/counter_test.exs b/test/name_badge/screen/ex_ratatui/counter_test.exs new file mode 100644 index 0000000..a66b3c9 --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -0,0 +1,73 @@ +defmodule NameBadge.Screen.ExRatatui.CounterTest do + use ExUnit.Case, async: true + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Widgets.Paragraph + alias NameBadge.Screen.ExRatatui.Counter + + describe "mount/1" do + test "starts at count 0" do + assert {:ok, %{count: 0}} = Counter.mount([]) + end + end + + describe "handle_event/2" do + test "up increments" do + assert {:noreply, %{count: 1}} = Counter.handle_event(key("up"), %{count: 0}) + assert {:noreply, %{count: 6}} = Counter.handle_event(key("up"), %{count: 5}) + end + + test "down decrements (no floor — negative counts are allowed)" do + assert {:noreply, %{count: 4}} = Counter.handle_event(key("down"), %{count: 5}) + assert {:noreply, %{count: -1}} = Counter.handle_event(key("down"), %{count: 0}) + end + + test "home resets to 0 from any value" do + assert {:noreply, %{count: 0}} = Counter.handle_event(key("home"), %{count: 42}) + assert {:noreply, %{count: 0}} = Counter.handle_event(key("home"), %{count: -7}) + end + + test "ignores unmapped keys" do + state = %{count: 3} + assert {:noreply, ^state} = Counter.handle_event(key("left"), state) + assert {:noreply, ^state} = Counter.handle_event(key("q"), state) + end + end + + describe "render/2" do + test "produces a header, count line, and two footer hints" do + widgets = Counter.render(%{count: 7}, %Rect{x: 0, y: 0, width: 66, height: 37}) + + assert length(widgets) == 4 + + texts = + Enum.map(widgets, fn {%Paragraph{text: text}, _rect} -> text end) + + assert "COUNTER" in texts + assert "COUNT: 7" in texts + assert "A: +1 A LONG: RESET" in texts + assert "B: -1 B LONG: BACK" in texts + end + + test "every widget is centered horizontally" do + widgets = Counter.render(%{count: 0}, %Rect{x: 0, y: 0, width: 66, height: 37}) + + for {%Paragraph{alignment: alignment}, _rect} <- widgets do + assert alignment == :center + end + end + + test "rects fit within the frame" do + frame = %Rect{x: 0, y: 0, width: 66, height: 37} + widgets = Counter.render(%{count: 0}, frame) + + for {_widget, rect} <- widgets do + assert rect.x + rect.width <= frame.width + assert rect.y + rect.height <= frame.height + end + end + end + + defp key(code), do: %Key{code: code, kind: "press", modifiers: []} +end From cf23a77901be9134822862dfc5fd079d863e7a19 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:50:27 +0200 Subject: [PATCH 06/30] feat(screen): register Counter in the top-level menu --- lib/name_badge/screen/ex_ratatui.ex | 58 +++++++++++++++++++++++++++++ lib/name_badge/screen/top_level.ex | 1 + 2 files changed, 59 insertions(+) diff --git a/lib/name_badge/screen/ex_ratatui.ex b/lib/name_badge/screen/ex_ratatui.ex index bd7cbee..bed5ecc 100644 --- a/lib/name_badge/screen/ex_ratatui.ex +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -124,4 +124,62 @@ defmodule NameBadge.Screen.ExRatatui do end defp blank_png(), do: Raster.new() |> Raster.to_png() + + @doc """ + Generates a `NameBadge.Screen` module that hosts a fixed + `ExRatatui.App`. Use this from per-demo menu-facing screens that the + top-level menu can navigate to without passing mount args (which + `NameBadge.ScreenManager.navigate/1` does not carry). + + defmodule NameBadge.Screen.Counter do + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Counter + end + + Accepts the same options as `mount/2`: + + * `:app` (required) — module implementing `ExRatatui.App`. + * `:app_opts` (optional) — keyword list forwarded to + `ExRatatui.Server` and the App's `mount/1`. + * `:key_map` (optional) — overrides the default badge-button to + `t:ExRatatui.Event.Key.t/0` mapping. + """ + defmacro __using__(opts) do + app = Keyword.fetch!(opts, :app) + app_opts = Keyword.get(opts, :app_opts, []) + key_map = Keyword.get(opts, :key_map, nil) + + quote do + use NameBadge.Screen + + @adapter NameBadge.Screen.ExRatatui + @adapter_args [ + app: unquote(app), + app_opts: unquote(app_opts) + ] + @adapter_key_map unquote(key_map) + + @impl NameBadge.Screen + def mount(_args, screen) do + adapter_args = + if @adapter_key_map, + do: [{:key_map, @adapter_key_map} | @adapter_args], + else: @adapter_args + + @adapter.mount(adapter_args, screen) + end + + @impl NameBadge.Screen + def render(assigns), do: @adapter.render(assigns) + + @impl NameBadge.Screen + def handle_button(button, press_type, screen), + do: @adapter.handle_button(button, press_type, screen) + + @impl NameBadge.Screen + def handle_info(message, screen), do: @adapter.handle_info(message, screen) + + @impl NameBadge.Screen + def terminate(reason, screen), do: @adapter.terminate(reason, screen) + end + end end diff --git a/lib/name_badge/screen/top_level.ex b/lib/name_badge/screen/top_level.ex index 4fd787f..3d818d9 100644 --- a/lib/name_badge/screen/top_level.ex +++ b/lib/name_badge/screen/top_level.ex @@ -7,6 +7,7 @@ defmodule NameBadge.Screen.TopLevel do {Screen.NameBadge, "Name Badge"}, {Screen.Gallery, "Gallery"}, {Screen.Snake, "Snake"}, + {Screen.Counter, "Counter"}, {Screen.Weather, "Weather"}, {Screen.Settings, "Device Settings"} ] From 136efd24a29f61b15bed8bad40c1f4d2d8e7119d Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 18:56:59 +0200 Subject: [PATCH 07/30] fix(screen): use ExRatatui.App so __runtime__/0 is injected --- lib/name_badge/screen/ex_ratatui/counter.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index 6aa072a..587663c 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -18,7 +18,7 @@ defmodule NameBadge.Screen.ExRatatui.Counter do the screen's mount args, not editing this module. """ - @behaviour ExRatatui.App + use ExRatatui.App alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect From aeaa08d49396211dea278a02e08cc65bbe64bafd Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:02:36 +0200 Subject: [PATCH 08/30] fix(screen): raise on missing use ExRatatui.App at mount --- lib/name_badge/screen/ex_ratatui.ex | 36 +++++++++++- test/name_badge/screen/ex_ratatui_test.exs | 67 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/lib/name_badge/screen/ex_ratatui.ex b/lib/name_badge/screen/ex_ratatui.ex index bed5ecc..08dd541 100644 --- a/lib/name_badge/screen/ex_ratatui.ex +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -27,11 +27,18 @@ defmodule NameBadge.Screen.ExRatatui do ## Mount args mount: [ - app: MyTui, # required, implements ExRatatui.App + app: MyTui, # required; the module MUST `use ExRatatui.App` app_opts: [], # optional, forwarded to the App's mount/1 key_map: %{...} # optional, overrides the button mapping below ] + > **Why `use ExRatatui.App` and not just `@behaviour`?** The runtime + > calls `mod.__runtime__/0` to dispatch between the callback and + > reducer styles. That function is injected by `use ExRatatui.App` + > and does not exist on a module that only declares + > `@behaviour ExRatatui.App`. The adapter raises an `ArgumentError` + > at `mount/2` if the App module is missing it. + ## Default key map | Badge input | ExRatatui.Event.Key | @@ -65,6 +72,8 @@ defmodule NameBadge.Screen.ExRatatui do app_opts = Keyword.get(args, :app_opts, []) key_map = Keyword.get(args, :key_map, @default_key_map) + ensure_ex_ratatui_app!(app_mod) + {cols, rows} = Raster.grid_size() session = CellSession.new(cols, rows) @@ -125,6 +134,31 @@ defmodule NameBadge.Screen.ExRatatui do defp blank_png(), do: Raster.new() |> Raster.to_png() + defp ensure_ex_ratatui_app!(app_mod) do + Code.ensure_loaded(app_mod) + + cond do + not Code.ensure_loaded?(app_mod) -> + raise ArgumentError, + "App module #{inspect(app_mod)} could not be loaded. " <> + "Check the spelling and make sure it compiles." + + not function_exported?(app_mod, :__runtime__, 0) -> + raise ArgumentError, """ + #{inspect(app_mod)} does not export __runtime__/0 — did you + forget `use ExRatatui.App`? + + Declaring `@behaviour ExRatatui.App` alone is not enough; the + runtime relies on __runtime__/0 to choose between the callback + and reducer styles. Switch to `use ExRatatui.App` and the + function will be injected for you. + """ + + true -> + :ok + end + end + @doc """ Generates a `NameBadge.Screen` module that hosts a fixed `ExRatatui.App`. Use this from per-demo menu-facing screens that the diff --git a/test/name_badge/screen/ex_ratatui_test.exs b/test/name_badge/screen/ex_ratatui_test.exs index d0e652d..a5c1d5b 100644 --- a/test/name_badge/screen/ex_ratatui_test.exs +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -7,6 +7,73 @@ defmodule NameBadge.Screen.ExRatatuiTest do alias NameBadge.Screen alias NameBadge.Screen.ExRatatui, as: Adapter + defmodule MerelyBehaviourApp do + @moduledoc false + # Intentionally declares the behaviour but does NOT `use + # ExRatatui.App`, so __runtime__/0 is never injected. This is the + # exact mistake that crashed Counter at runtime — the adapter must + # detect it at mount time and raise a clear error. + @behaviour ExRatatui.App + + @impl true + def mount(_), do: {:ok, %{}} + + @impl true + def render(_, _), do: [] + + @impl true + def handle_event(_, state), do: {:noreply, state} + end + + defmodule HelloApp do + @moduledoc false + # A minimal correctly-defined ExRatatui.App used to smoke-test the + # full adapter → Server → CellSession → cell_writer path. + use ExRatatui.App + + alias ExRatatui.Layout.Rect + alias ExRatatui.Widgets.Paragraph + + @impl true + def mount(_opts), do: {:ok, %{}} + + @impl true + def render(_state, frame) do + [ + {%Paragraph{text: "HI"}, + %Rect{x: 0, y: 0, width: frame.width, height: frame.height}} + ] + end + + @impl true + def handle_event(_event, state), do: {:noreply, state} + end + + describe "mount/2 guard" do + test "raises a helpful error when the app module isn't `use ExRatatui.App`" do + msg = + try do + Adapter.mount([app: MerelyBehaviourApp], %Screen{module: Adapter}) + rescue + e in ArgumentError -> Exception.message(e) + end + + assert msg =~ "does not export __runtime__/0" + assert msg =~ "use ExRatatui.App" + end + + test "raises a clear error when the app module can't be loaded at all" do + msg = + try do + Adapter.mount([app: NotAModule.At.All], %Screen{module: Adapter}) + rescue + e in ArgumentError -> Exception.message(e) + end + + assert msg =~ "could not be loaded" + end + end + describe "handle_button/3" do test "forwards single A as Key{code: \"up\"} to the server" do screen = screen_with_server() From 42ca143111dad9e549e7b24a0c3ff958297609f0 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:04:37 +0200 Subject: [PATCH 09/30] test(screen): cover Screen.ExRatatui server boot path --- test/name_badge/screen/ex_ratatui_test.exs | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/name_badge/screen/ex_ratatui_test.exs b/test/name_badge/screen/ex_ratatui_test.exs index a5c1d5b..99a6e99 100644 --- a/test/name_badge/screen/ex_ratatui_test.exs +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -49,6 +49,46 @@ defmodule NameBadge.Screen.ExRatatuiTest do def handle_event(_event, state), do: {:noreply, state} end + describe "mount/2 against a real Server" do + setup do + # ExRatatui.Server emits :telemetry events on init; without the + # app running, they log "Failed to lookup telemetry handlers" + # warnings that drown legitimate test output. Starting :telemetry + # is enough to keep them quiet — we don't actually attach any + # handlers. + {:ok, _} = Application.ensure_all_started(:telemetry) + :ok + end + + test "starts the Server, paints an initial frame, and ferries diffs to the screen pid" do + {:ok, screen} = Adapter.mount([app: HelloApp], %Screen{module: Adapter}) + + on_exit(fn -> stop_server(screen) end) + + assert is_pid(screen.assigns.server) + assert Process.alive?(screen.assigns.server) + assert %ExRatatui.CellSession{} = screen.assigns.session + + # The Server's first render fires the cell_writer synchronously + # during init. Because the adapter's mount/2 sets `screen_pid = + # self()` (us, the test process), the diff lands in our mailbox. + assert_receive {:ex_ratatui_diff, %ExRatatui.CellSession.Diff{ops: ops}}, 500 + assert ops != [] + + # Feeding that diff back through handle_info/2 should produce a + # PNG with at least one ink pixel — proving the full + # cell_writer → Raster → PNG path works end-to-end. + diff = %ExRatatui.CellSession.Diff{ + width: 66, + height: 37, + ops: ops + } + + {:noreply, updated} = Adapter.handle_info({:ex_ratatui_diff, diff}, screen) + assert <<137, 80, 78, 71, _::binary>> = updated.assigns.png + end + end + describe "mount/2 guard" do test "raises a helpful error when the app module isn't `use ExRatatui.App`" do msg = @@ -200,4 +240,12 @@ defmodule NameBadge.Screen.ExRatatuiTest do {:button_2, :single_press} => %Key{code: "down", kind: "press", modifiers: []} } end + + defp stop_server(screen) do + if pid = screen.assigns[:server], do: GenServer.stop(pid, :normal, 1_000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end end From 8032ace951ba92659ff66cfa26f857a764dbff7f Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:08:19 +0200 Subject: [PATCH 10/30] feat(screen): trap exits and render crash + initial frames --- lib/name_badge/screen/ex_ratatui.ex | 80 +++++++++++++++++++++- test/name_badge/screen/ex_ratatui_test.exs | 78 ++++++++++++++++----- 2 files changed, 138 insertions(+), 20 deletions(-) diff --git a/lib/name_badge/screen/ex_ratatui.ex b/lib/name_badge/screen/ex_ratatui.ex index 08dd541..8229ef9 100644 --- a/lib/name_badge/screen/ex_ratatui.ex +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -56,8 +56,12 @@ defmodule NameBadge.Screen.ExRatatui do use NameBadge.Screen + require Logger + alias ExRatatui.CellSession alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Widgets.Paragraph alias NameBadge.ExRatatui.Raster @default_key_map %{ @@ -74,6 +78,12 @@ defmodule NameBadge.Screen.ExRatatui do ensure_ex_ratatui_app!(app_mod) + # Trap exits so we can catch a crashed Server, render a fallback + # frame, and stay on screen until the user long-presses B to go + # back — instead of dying via the link and leaving the badge stuck + # on the last good frame. + Process.flag(:trap_exit, true) + {cols, rows} = Raster.grid_size() session = CellSession.new(cols, rows) @@ -89,19 +99,35 @@ defmodule NameBadge.Screen.ExRatatui do {:ok, server} = ExRatatui.Transport.start_server(server_opts) + # The Server's first render fires the cell_writer synchronously + # during init, so by the time start_server returns, the initial + # diff is already in our mailbox. Drain it now and seed assigns + # with real content — otherwise the base Screen's first render + # paints a blank PNG before our handle_info catches up, causing a + # one-frame flicker on every screen switch. + {raster, png} = drain_initial_frame(Raster.new()) + {:ok, screen |> assign(:server, server) |> assign(:session, session) |> assign(:key_map, key_map) - |> assign(:raster, Raster.new()) - |> assign(:png, blank_png())} + |> assign(:raster, raster) + |> assign(:png, png)} end @impl NameBadge.Screen def render(assigns), do: assigns.png @impl NameBadge.Screen + def handle_button(_button, _press_type, %{assigns: %{server: nil}} = screen) do + # Server crashed earlier; the user is looking at a "TUI CRASHED" + # frame. Forwarding events would crash us too. Long-press B is + # intercepted by the base Screen for navigate :back, so the user + # can still get out. + {:noreply, screen} + end + def handle_button(button, press_type, screen) do case Map.fetch(screen.assigns.key_map, {button, press_type}) do {:ok, %Key{} = event} -> @@ -124,6 +150,19 @@ defmodule NameBadge.Screen.ExRatatui do |> assign(:png, png)} end + def handle_info( + {:EXIT, pid, reason}, + %{assigns: %{server: server}} = screen + ) + when pid == server do + Logger.error("ExRatatui.Server crashed: #{inspect(reason)}") + + {:noreply, + screen + |> assign(:server, nil) + |> assign(:png, crashed_png())} + end + def handle_info(_other, screen), do: {:noreply, screen} @impl NameBadge.Screen @@ -134,6 +173,43 @@ defmodule NameBadge.Screen.ExRatatui do defp blank_png(), do: Raster.new() |> Raster.to_png() + # Awaits the first cell_writer message and folds it into the + # provided raster. Falls back to a blank PNG if no diff is in the + # mailbox after a short window — better to start with blank and + # update on the next handle_info than to deadlock the screen. + @initial_frame_timeout 100 + defp drain_initial_frame(raster) do + receive do + {:ex_ratatui_diff, diff} -> + raster = Raster.apply_diff(raster, diff) + {raster, Raster.to_png(raster)} + after + @initial_frame_timeout -> {raster, blank_png()} + end + end + + defp crashed_png() do + {cols, rows} = Raster.grid_size() + session = CellSession.new(cols, rows) + + mid = div(rows, 2) + + widgets = [ + {%Paragraph{text: "TUI CRASHED", alignment: :center}, + %Rect{x: 0, y: mid - 1, width: cols, height: 1}}, + {%Paragraph{text: "LONG-PRESS B FOR MENU", alignment: :center}, + %Rect{x: 0, y: mid + 1, width: cols, height: 1}} + ] + + :ok = CellSession.draw(session, widgets) + snapshot = CellSession.take_cells(session) + :ok = CellSession.close(session) + + Raster.new() + |> Raster.put_snapshot(snapshot) + |> Raster.to_png() + end + defp ensure_ex_ratatui_app!(app_mod) do Code.ensure_loaded(app_mod) diff --git a/test/name_badge/screen/ex_ratatui_test.exs b/test/name_badge/screen/ex_ratatui_test.exs index 99a6e99..3e9414b 100644 --- a/test/name_badge/screen/ex_ratatui_test.exs +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -60,7 +60,7 @@ defmodule NameBadge.Screen.ExRatatuiTest do :ok end - test "starts the Server, paints an initial frame, and ferries diffs to the screen pid" do + test "starts the Server and seeds non-blank initial content into assigns" do {:ok, screen} = Adapter.mount([app: HelloApp], %Screen{module: Adapter}) on_exit(fn -> stop_server(screen) end) @@ -69,23 +69,14 @@ defmodule NameBadge.Screen.ExRatatuiTest do assert Process.alive?(screen.assigns.server) assert %ExRatatui.CellSession{} = screen.assigns.session - # The Server's first render fires the cell_writer synchronously - # during init. Because the adapter's mount/2 sets `screen_pid = - # self()` (us, the test process), the diff lands in our mailbox. - assert_receive {:ex_ratatui_diff, %ExRatatui.CellSession.Diff{ops: ops}}, 500 - assert ops != [] - - # Feeding that diff back through handle_info/2 should produce a - # PNG with at least one ink pixel — proving the full - # cell_writer → Raster → PNG path works end-to-end. - diff = %ExRatatui.CellSession.Diff{ - width: 66, - height: 37, - ops: ops - } - - {:noreply, updated} = Adapter.handle_info({:ex_ratatui_diff, diff}, screen) - assert <<137, 80, 78, 71, _::binary>> = updated.assigns.png + # mount/2 drains the Server's first cell_writer message and + # folds it into the raster before returning, so :png is real + # content — not the blank fallback. This is what kills the + # one-frame blank flicker on every screen switch. + blank = NameBadge.ExRatatui.Raster.new() |> NameBadge.ExRatatui.Raster.to_png() + assert <<137, 80, 78, 71, _::binary>> = screen.assigns.png + assert screen.assigns.png != blank + assert screen.assigns.raster.cells != %{} end end @@ -164,6 +155,57 @@ defmodule NameBadge.Screen.ExRatatuiTest do end end + describe "server crash handling" do + test "an EXIT from the hosted server replaces the PNG with a crash frame and clears :server" do + png_before = <<137, 80, 78, 71, 13, 10, 26, 10, "before">> + + fake_server = spawn(fn -> :ok end) + + screen = %Screen{ + module: Adapter, + assigns: %{server: fake_server, png: png_before} + } + + exit_msg = {:EXIT, fake_server, :killed} + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert {:noreply, updated} = Adapter.handle_info(exit_msg, screen) + + assert updated.assigns.server == nil + assert <<137, 80, 78, 71, _::binary>> = updated.assigns.png + assert updated.assigns.png != png_before + + send(self(), {:result, updated}) + end) + + assert log =~ "ExRatatui.Server crashed" + assert log =~ ":killed" + end + + test "EXIT from an unrelated pid is ignored (treated as a stray message)" do + stranger = spawn(fn -> :ok end) + our_server = spawn(fn -> Process.sleep(:infinity) end) + + screen = %Screen{ + module: Adapter, + assigns: %{server: our_server, png: <<>>} + } + + assert {:noreply, ^screen} = Adapter.handle_info({:EXIT, stranger, :normal}, screen) + end + + test "handle_button is a no-op once the server has been cleared" do + screen = %Screen{ + module: Adapter, + assigns: %{server: nil, key_map: default_key_map()} + } + + assert {:noreply, ^screen} = Adapter.handle_button(:button_1, :single_press, screen) + refute_received {:ex_ratatui_event, _} + end + end + describe "handle_info/2 on cell diffs" do test "merges the diff into the raster and re-encodes the PNG" do screen = %Screen{ From 30cdc31141ba726e84193254b68082ec58c03e05 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:10:37 +0200 Subject: [PATCH 11/30] feat(ex_ratatui): invert reversed cells in the rasteriser --- lib/name_badge/ex_ratatui/raster.ex | 48 +++++++++++++++---- test/name_badge/ex_ratatui/raster_test.exs | 54 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/lib/name_badge/ex_ratatui/raster.ex b/lib/name_badge/ex_ratatui/raster.ex index 883a081..8c17a97 100644 --- a/lib/name_badge/ex_ratatui/raster.ex +++ b/lib/name_badge/ex_ratatui/raster.ex @@ -25,13 +25,23 @@ defmodule NameBadge.ExRatatui.Raster do bottom). The unused strip stays paper-white. Cell sessions should be constructed at this grid size — see `grid_size/0`. - ## Style support (v1) - - Glyphs render as ink-on-paper regardless of `fg`/`bg`. Modifiers - (bold, italic, underlined, reversed, …) and the `:skip` flag are - ignored except `:skip` cells render as paper. Reverse-video and - bold-as-double-strike will land in a follow-up; they aren't needed - for the v1 demo apps. + ## Style support + + The 1-bit display has no concept of color, so any non-default + `bg` color or the `:reversed` modifier collapses to "this cell is + inverted": the glyph paints as paper on an ink background, instead + of ink on paper. The `fg` color is otherwise ignored — there is + only ink. Other modifiers (bold, italic, underlined, …) are + ignored. `:skip` cells render as paper. + + | Cell shape | Pixels | + | ----------------------------------------------- | ------------- | + | `bg: :reset`, no `:reversed` | ink-on-paper | + | `bg: `, or `:reversed` in mods| paper-on-ink | + | `:skip: true` | all paper | + + Bold-as-double-strike, underline-as-bottom-row, and grayscale `fg` + / `bg` mappings are deferred until a demo needs them. """ import Bitwise @@ -138,22 +148,40 @@ defmodule NameBadge.ExRatatui.Raster do defp cell_pixel_row(nil, _sub_y), do: @paper_cell_row defp cell_pixel_row(%Cell{skip: true}, _sub_y), do: @paper_cell_row - defp cell_pixel_row(%Cell{symbol: symbol}, sub_y) do + defp cell_pixel_row(%Cell{symbol: symbol} = cell, sub_y) do byte = symbol |> codepoint_of() |> Font.glyph() |> :binary.at(sub_y) + {ink, paper} = ink_and_paper(cell) + Enum.map(0..(@cell_w - 1), fn i -> case byte >>> (7 - i) &&& 1 do - 1 -> @ink - 0 -> @paper + 1 -> ink + 0 -> paper end end) |> :binary.list_to_bin() end + # Returns the {ink_byte, paper_byte} pair to use for a cell. On the + # 1-bit e-ink display, "color" collapses to "are we inverted?" — a + # cell with a non-default bg or the `:reversed` modifier paints + # paper glyphs on an ink background; everything else paints the + # other way around. + defp ink_and_paper(%Cell{bg: bg, modifiers: modifiers}) do + if inverted?(bg, modifiers) do + {@paper, @ink} + else + {@ink, @paper} + end + end + + defp inverted?(:reset, modifiers), do: :reversed in modifiers + defp inverted?(_bg, _modifiers), do: true + defp codepoint_of(""), do: ?\s defp codepoint_of(symbol) when is_binary(symbol) do diff --git a/test/name_badge/ex_ratatui/raster_test.exs b/test/name_badge/ex_ratatui/raster_test.exs index 4e65820..8d10c6f 100644 --- a/test/name_badge/ex_ratatui/raster_test.exs +++ b/test/name_badge/ex_ratatui/raster_test.exs @@ -82,6 +82,60 @@ defmodule NameBadge.ExRatatui.RasterTest do end end + describe "style: inversion" do + test "a cell with a non-default bg paints paper-on-ink (filling the whole cell rect)" do + inverted = %Cell{ + cell(0, 0, "A") + | bg: :white + } + + bin = + Raster.new() + |> Raster.put_snapshot(snapshot([inverted])) + |> Raster.to_grayscale() + + # 'A's first row is `.###.` so pixels (1, 0), (2, 0), (3, 0) are + # the glyph (now paper). The empties — (0, 0), (4, 0) — and the + # right-spacer column (5, 0) become ink. + assert byte_at(bin, 0, 0) == 0 + assert byte_at(bin, 1, 0) == 255 + assert byte_at(bin, 2, 0) == 255 + assert byte_at(bin, 3, 0) == 255 + assert byte_at(bin, 4, 0) == 0 + assert byte_at(bin, 5, 0) == 0 + + # Bottom inter-line spacer (row 7) is normally paper but in an + # inverted cell it's ink — covering the full cell rect. + for x <- 0..5 do + assert byte_at(bin, x, 7) == 0 + end + end + + test "a cell with the :reversed modifier paints paper-on-ink even when bg is :reset" do + inverted = %Cell{cell(0, 0, "A") | modifiers: [:reversed]} + + bin = + Raster.new() + |> Raster.put_snapshot(snapshot([inverted])) + |> Raster.to_grayscale() + + assert byte_at(bin, 1, 0) == 255 + assert byte_at(bin, 5, 0) == 0 + end + + test "default styling is unchanged (still ink-on-paper)" do + bin = + Raster.new() + |> Raster.put_snapshot(snapshot([cell(0, 0, "A")])) + |> Raster.to_grayscale() + + # Same shape as the existing canonical-A test — preserved as the + # control case for the inversion changes. + assert byte_at(bin, 1, 0) == 0 + assert byte_at(bin, 5, 0) == 255 + end + end + describe "apply_diff/2" do test "merges into the existing cell map without disturbing other cells" do base = From 1e757e44e216ccc594b28966a723050e81d33813 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:21:06 +0200 Subject: [PATCH 12/30] feat(ex_ratatui): extend font with lowercase, box, and block glyphs --- lib/name_badge/ex_ratatui/font.ex | 502 ++++++++++++++++++++++- test/name_badge/ex_ratatui/font_test.exs | 53 ++- 2 files changed, 523 insertions(+), 32 deletions(-) diff --git a/lib/name_badge/ex_ratatui/font.ex b/lib/name_badge/ex_ratatui/font.ex index 9388ae3..7dd538d 100644 --- a/lib/name_badge/ex_ratatui/font.ex +++ b/lib/name_badge/ex_ratatui/font.ex @@ -13,23 +13,40 @@ defmodule NameBadge.ExRatatui.Font do `glyph/1` returns an 8-byte binary, one byte per row from top to bottom. Within each row, the most-significant bit is the leftmost - pixel of the cell. Only the top six bits of each byte are - meaningful; the bottom two are always zero (the rightmost column of - the cell is the inter-cell spacer). Row 7 is always all zeros (the - inter-line spacer). + pixel of the cell. Bits 7–2 hold the 6 cell columns; bits 1–0 are + always zero (unused). iex> bitmap = NameBadge.ExRatatui.Font.glyph(?A) iex> byte_size(bitmap) 8 + ## Source format (5×7 vs 6×8) + + Glyphs are declared as ASCII-art blocks (`#` ink, `.` paper) and + parsed to bitmaps at compile time. Two source shapes are accepted: + + * **5×7** (5 columns × 7 rows) — the typographic majority. The + parser implicitly adds a paper column on the right (inter-cell + spacing) and a paper row at the bottom (inter-line spacing). + Used for letters, digits, and most punctuation. + + * **6×8** (6 columns × 8 rows) — used for glyphs that need to + fill the entire cell rectangle to render correctly across cell + boundaries: box-drawing characters, block elements, full-bleed + shading. Author controls every pixel. + ## Coverage - v1 covers digits 0–9, uppercase A–Z, space, and common ASCII - punctuation — enough for the demo apps that drive the cell-mode - rendering pipeline. Codepoints outside this set fall back to a - visually-distinct hatched box so they are obvious in renderings - rather than silently blank. Lowercase letters and box-drawing glyphs - are deferred to a follow-up. + Currently encoded: + + * Digits `0`–`9` + * Uppercase `A`–`Z`, lowercase `a`–`z` + * Space and common ASCII punctuation + * Light single-line box-drawing: `─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼` + * Block elements: `█ ▀ ▄ ░ ▒ ▓` + + Codepoints outside this set fall back to a visually-distinct hatched + box so they are obvious in renderings rather than silently blank. """ @cell_width 6 @@ -628,23 +645,465 @@ defmodule NameBadge.ExRatatui.Font do ..... ..... ##### - """ + """, + ?a => """ + ..... + ..... + .###. + ....# + .#### + #...# + .#### + """, + ?b => """ + #.... + #.... + ####. + #...# + #...# + #...# + ####. + """, + ?c => """ + ..... + ..... + .###. + #.... + #.... + #.... + .###. + """, + ?d => """ + ....# + ....# + .#### + #...# + #...# + #...# + .#### + """, + ?e => """ + ..... + ..... + .###. + #...# + ##### + #.... + .###. + """, + ?f => """ + ..##. + .#... + .###. + .#... + .#... + .#... + .#... + """, + ?g => """ + ..... + ..... + .#### + #...# + .#### + ....# + .###. + """, + ?h => """ + #.... + #.... + ####. + #...# + #...# + #...# + #...# + """, + ?i => """ + ..#.. + ..... + .##.. + ..#.. + ..#.. + ..#.. + .###. + """, + ?j => """ + ....# + ..... + ...## + ....# + ....# + ....# + .###. + """, + ?k => """ + #.... + #.... + #...# + #..#. + ###.. + #..#. + #...# + """, + ?l => """ + .##.. + ..#.. + ..#.. + ..#.. + ..#.. + ..#.. + .###. + """, + ?m => """ + ..... + ..... + ##.#. + #.#.# + #.#.# + #...# + #...# + """, + ?n => """ + ..... + ..... + ####. + #...# + #...# + #...# + #...# + """, + ?o => """ + ..... + ..... + .###. + #...# + #...# + #...# + .###. + """, + ?p => """ + ..... + ..... + ####. + #...# + ####. + #.... + #.... + """, + ?q => """ + ..... + ..... + .#### + #...# + .#### + ....# + ....# + """, + ?r => """ + ..... + ..... + #.##. + ##... + #.... + #.... + #.... + """, + ?s => """ + ..... + ..... + .#### + #.... + .###. + ....# + ####. + """, + ?t => """ + .#... + .#... + ###.. + .#... + .#... + .#... + ..##. + """, + ?u => """ + ..... + ..... + #...# + #...# + #...# + #...# + .#### + """, + ?v => """ + ..... + ..... + #...# + #...# + #...# + .#.#. + ..#.. + """, + ?w => """ + ..... + ..... + #...# + #...# + #.#.# + #.#.# + .#.#. + """, + ?x => """ + ..... + ..... + #...# + .#.#. + ..#.. + .#.#. + #...# + """, + ?y => """ + ..... + ..... + #...# + #...# + .#### + ....# + ####. + """, + ?z => """ + ..... + ..... + ##### + ....# + ..##. + #.... + ##### + """, + # Light single-line box-drawing (Unicode U+2500..U+253C). 6×8 so + # they span the full cell. Vertical sits at column 2; horizontal + # at row 3. + 0x2500 => + """ + ...... + ...... + ...... + ###### + ...... + ...... + ...... + ...... + """, + 0x2502 => + """ + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + """, + 0x250C => + """ + ...... + ...... + ...... + ..#### + ..#... + ..#... + ..#... + ..#... + """, + 0x2510 => + """ + ...... + ...... + ...... + ###... + ..#... + ..#... + ..#... + ..#... + """, + 0x2514 => + """ + ..#... + ..#... + ..#... + ..#### + ...... + ...... + ...... + ...... + """, + 0x2518 => + """ + ..#... + ..#... + ..#... + ###... + ...... + ...... + ...... + ...... + """, + 0x251C => + """ + ..#... + ..#... + ..#... + ..#### + ..#... + ..#... + ..#... + ..#... + """, + 0x2524 => + """ + ..#... + ..#... + ..#... + ####.. + ..#... + ..#... + ..#... + ..#... + """, + 0x252C => + """ + ...... + ...... + ...... + ###### + ..#... + ..#... + ..#... + ..#... + """, + 0x2534 => + """ + ..#... + ..#... + ..#... + ###### + ...... + ...... + ...... + ...... + """, + 0x253C => + """ + ..#... + ..#... + ..#... + ###### + ..#... + ..#... + ..#... + ..#... + """, + # Block elements (Unicode U+2580, U+2584, U+2588, U+2591..U+2593). + 0x2580 => + """ + ###### + ###### + ###### + ###### + ...... + ...... + ...... + ...... + """, + 0x2584 => + """ + ...... + ...... + ...... + ...... + ###### + ###### + ###### + ###### + """, + 0x2588 => + """ + ###### + ###### + ###### + ###### + ###### + ###### + ###### + ###### + """, + 0x2591 => + """ + .#..#. + ...... + #..#.. + ...... + .#..#. + ...... + #..#.. + ...... + """, + 0x2592 => + """ + #.#.#. + .#.#.# + #.#.#. + .#.#.# + #.#.#. + .#.#.# + #.#.#. + .#.#.# + """, + 0x2593 => + """ + .##### + #.#### + ##.### + ###.## + ####.# + #####. + .##### + #.#### + """ } # Compile-time conversion of each ASCII-art block to an 8-byte # binary. Done inline (no helper-function calls) because the module # itself isn't fully defined while its attributes are being # evaluated. + # + # Accepts two source shapes per glyph: + # + # * 5×7 — five columns × seven rows. Each row is right-padded with + # a paper column (inter-cell spacing); a blank row is appended + # to reach 8 rows total (inter-line spacing). + # * 6×8 — six columns × eight rows. Author controls every pixel. + # Used for box-drawing and block elements that must fill the + # full cell rect. @glyphs Map.new(@glyph_data, fn {codepoint, art} -> - rows = - art - |> String.split("\n", trim: true) - |> Enum.map(fn line -> + raw_rows = String.split(art, "\n", trim: true) + + row_chars = + Enum.map(raw_rows, fn line -> chars = String.graphemes(line) - 5 = length(chars) + case length(chars) do + 5 -> chars ++ ["."] + 6 -> chars + n -> raise "glyph #{inspect(codepoint)}: expected 5 or 6 cols per row, got #{n}" + end + end) + + rows = + Enum.map(row_chars, fn six_chars -> [b5, b4, b3, b2, b1, b0] = - Enum.map(chars ++ ["."], fn + Enum.map(six_chars, fn "#" -> 1 "." -> 0 end) @@ -652,8 +1111,13 @@ defmodule NameBadge.ExRatatui.Font do <> end) - 7 = length(rows) - bitmap = IO.iodata_to_binary([rows, <<0>>]) + bitmap = + case length(rows) do + 7 -> IO.iodata_to_binary([rows, <<0>>]) + 8 -> IO.iodata_to_binary(rows) + n -> raise "glyph #{inspect(codepoint)}: expected 7 or 8 rows, got #{n}" + end + 8 = byte_size(bitmap) {codepoint, bitmap} end) diff --git a/test/name_badge/ex_ratatui/font_test.exs b/test/name_badge/ex_ratatui/font_test.exs index fc081dc..21d416d 100644 --- a/test/name_badge/ex_ratatui/font_test.exs +++ b/test/name_badge/ex_ratatui/font_test.exs @@ -22,18 +22,14 @@ defmodule NameBadge.ExRatatui.FontTest do assert byte_size(Font.glyph(0x2764)) == 8 end - test "row 7 is always the inter-line spacer (zero)" do - for codepoint <- Font.codepoints() do - <<_top::7-bytes, last>> = Font.glyph(codepoint) - assert last == 0, "row 7 of glyph #{inspect(codepoint)} must be blank, got #{last}" - end - end - - test "bottom two bits of every row are always zero (rightmost column blank)" do + test "bottom two bits of every row are unused padding (always zero)" do + # Bits 7..2 hold the 6 cell columns; bits 1..0 are always zero + # regardless of source format (5×7 or 6×8). This is a structural + # invariant of the bitmap encoding itself. for codepoint <- Font.codepoints(), <> <- :binary.bin_to_list(Font.glyph(codepoint)) |> Enum.map(&<<&1>>) do assert Bitwise.band(row, 0b11) == 0, - "glyph #{inspect(codepoint)} has ink in the right-spacer column: row=#{row}" + "glyph #{inspect(codepoint)} has unexpected ink in the unused bottom 2 bits: row=#{row}" end end @@ -59,17 +55,48 @@ defmodule NameBadge.ExRatatui.FontTest do assert Font.has_glyph?(?0) refute Font.glyph(?0) == <<0, 0, 0, 0, 0, 0, 0, 0>> end + + test "─ (U+2500) fills row 3 across all 6 cell columns" do + # 6×8 source: row 3 is `######`, all other rows are blank. The + # rendered byte for row 3 has bits 7..2 set and bits 1..0 zero. + assert Font.glyph(0x2500) == + <<0, 0, 0, 0b11111100, 0, 0, 0, 0>> + end + + test "│ (U+2502) fills column 2 across all 8 rows (continuous vertical)" do + expected_row = <<0b00100000>> + assert Font.glyph(0x2502) == :binary.copy(expected_row, 8) + end + + test "█ (U+2588) is fully inked" do + assert Font.glyph(0x2588) == :binary.copy(<<0b11111100>>, 8) + end end describe "has_glyph?/1" do - test "is true for digits, uppercase, and common punctuation" do - for cp <- Enum.concat([?0..?9, ?A..?Z, [?\s, ?., ?,, ?:, ?+, ?-, ??, ?!]]) do + test "is true for digits, uppercase, lowercase, and common punctuation" do + for cp <- Enum.concat([?0..?9, ?A..?Z, ?a..?z, [?\s, ?., ?,, ?:, ?+, ?-, ??, ?!]]) do assert Font.has_glyph?(cp), "expected glyph for #{[cp]}" end end - test "is false for codepoints we haven't encoded yet (lowercase, emoji)" do - refute Font.has_glyph?(?a) + test "is true for the light single-line box-drawing set" do + box = [0x2500, 0x2502, 0x250C, 0x2510, 0x2514, 0x2518, 0x251C, 0x2524, 0x252C, 0x2534, 0x253C] + + for cp <- box do + assert Font.has_glyph?(cp), "expected glyph for U+#{Integer.to_string(cp, 16)}" + end + end + + test "is true for the basic block elements" do + blocks = [0x2580, 0x2584, 0x2588, 0x2591, 0x2592, 0x2593] + + for cp <- blocks do + assert Font.has_glyph?(cp), "expected glyph for U+#{Integer.to_string(cp, 16)}" + end + end + + test "is false for codepoints we haven't encoded (emoji)" do refute Font.has_glyph?(0x2764) end end From 2a1154f694250c5778d1b2c1f5a322e61a1266e2 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Thu, 7 May 2026 19:27:47 +0200 Subject: [PATCH 13/30] refactor(screen): polish Counter to exercise borders and reversed chips --- lib/name_badge/ex_ratatui/font.ex | 357 +++++++++--------- lib/name_badge/screen/ex_ratatui/counter.ex | 86 ++++- test/name_badge/ex_ratatui/font_test.exs | 14 +- .../screen/ex_ratatui/counter_test.exs | 67 +++- test/name_badge/screen/ex_ratatui_test.exs | 3 +- 5 files changed, 304 insertions(+), 223 deletions(-) diff --git a/lib/name_badge/ex_ratatui/font.ex b/lib/name_badge/ex_ratatui/font.ex index 7dd538d..bff24f0 100644 --- a/lib/name_badge/ex_ratatui/font.ex +++ b/lib/name_badge/ex_ratatui/font.ex @@ -883,194 +883,177 @@ defmodule NameBadge.ExRatatui.Font do # Light single-line box-drawing (Unicode U+2500..U+253C). 6×8 so # they span the full cell. Vertical sits at column 2; horizontal # at row 3. - 0x2500 => - """ - ...... - ...... - ...... - ###### - ...... - ...... - ...... - ...... - """, - 0x2502 => - """ - ..#... - ..#... - ..#... - ..#... - ..#... - ..#... - ..#... - ..#... - """, - 0x250C => - """ - ...... - ...... - ...... - ..#### - ..#... - ..#... - ..#... - ..#... - """, - 0x2510 => - """ - ...... - ...... - ...... - ###... - ..#... - ..#... - ..#... - ..#... - """, - 0x2514 => - """ - ..#... - ..#... - ..#... - ..#### - ...... - ...... - ...... - ...... - """, - 0x2518 => - """ - ..#... - ..#... - ..#... - ###... - ...... - ...... - ...... - ...... - """, - 0x251C => - """ - ..#... - ..#... - ..#... - ..#### - ..#... - ..#... - ..#... - ..#... - """, - 0x2524 => - """ - ..#... - ..#... - ..#... - ####.. - ..#... - ..#... - ..#... - ..#... - """, - 0x252C => - """ - ...... - ...... - ...... - ###### - ..#... - ..#... - ..#... - ..#... - """, - 0x2534 => - """ - ..#... - ..#... - ..#... - ###### - ...... - ...... - ...... - ...... - """, - 0x253C => - """ - ..#... - ..#... - ..#... - ###### - ..#... - ..#... - ..#... - ..#... - """, + 0x2500 => """ + ...... + ...... + ...... + ###### + ...... + ...... + ...... + ...... + """, + 0x2502 => """ + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + ..#... + """, + 0x250C => """ + ...... + ...... + ...... + ..#### + ..#... + ..#... + ..#... + ..#... + """, + 0x2510 => """ + ...... + ...... + ...... + ###... + ..#... + ..#... + ..#... + ..#... + """, + 0x2514 => """ + ..#... + ..#... + ..#... + ..#### + ...... + ...... + ...... + ...... + """, + 0x2518 => """ + ..#... + ..#... + ..#... + ###... + ...... + ...... + ...... + ...... + """, + 0x251C => """ + ..#... + ..#... + ..#... + ..#### + ..#... + ..#... + ..#... + ..#... + """, + 0x2524 => """ + ..#... + ..#... + ..#... + ####.. + ..#... + ..#... + ..#... + ..#... + """, + 0x252C => """ + ...... + ...... + ...... + ###### + ..#... + ..#... + ..#... + ..#... + """, + 0x2534 => """ + ..#... + ..#... + ..#... + ###### + ...... + ...... + ...... + ...... + """, + 0x253C => """ + ..#... + ..#... + ..#... + ###### + ..#... + ..#... + ..#... + ..#... + """, # Block elements (Unicode U+2580, U+2584, U+2588, U+2591..U+2593). - 0x2580 => - """ - ###### - ###### - ###### - ###### - ...... - ...... - ...... - ...... - """, - 0x2584 => - """ - ...... - ...... - ...... - ...... - ###### - ###### - ###### - ###### - """, - 0x2588 => - """ - ###### - ###### - ###### - ###### - ###### - ###### - ###### - ###### - """, - 0x2591 => - """ - .#..#. - ...... - #..#.. - ...... - .#..#. - ...... - #..#.. - ...... - """, - 0x2592 => - """ - #.#.#. - .#.#.# - #.#.#. - .#.#.# - #.#.#. - .#.#.# - #.#.#. - .#.#.# - """, - 0x2593 => - """ - .##### - #.#### - ##.### - ###.## - ####.# - #####. - .##### - #.#### - """ + 0x2580 => """ + ###### + ###### + ###### + ###### + ...... + ...... + ...... + ...... + """, + 0x2584 => """ + ...... + ...... + ...... + ...... + ###### + ###### + ###### + ###### + """, + 0x2588 => """ + ###### + ###### + ###### + ###### + ###### + ###### + ###### + ###### + """, + 0x2591 => """ + .#..#. + ...... + #..#.. + ...... + .#..#. + ...... + #..#.. + ...... + """, + 0x2592 => """ + #.#.#. + .#.#.# + #.#.#. + .#.#.# + #.#.#. + .#.#.# + #.#.#. + .#.#.# + """, + 0x2593 => """ + .##### + #.#### + ##.### + ###.## + ####.# + #####. + .##### + #.#### + """ } # Compile-time conversion of each ASCII-art block to an 8-byte diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index 587663c..8453cbd 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -1,43 +1,81 @@ defmodule NameBadge.Screen.ExRatatui.Counter do @moduledoc """ A two-button TUI counter — the first end-to-end demo of the - `NameBadge.Screen.ExRatatui` adapter. + `NameBadge.Screen.ExRatatui` adapter and the showcase that exercises + the rasterer's reverse-video support along with the font's + lowercase + box-drawing coverage. + + ## Layout + + ┌─ counter ────────────────────────────────┐ + │ │ + │ count: 42 │ + │ │ + └──────────────────────────────────────────┘ + + [ A ] +1 [ A long ] reset + [ B ] -1 [ B long ] back + + The bracketed key labels render in reverse-video — paper glyphs on + an ink background — so the user can see at a glance which inputs + the screen responds to. The Block border, title, and lowercase + text all exercise font + raster paths the original Counter didn't. ## Controls - | Key (TUI) | Badge button | Action | - | -------------------- | ------------------ | ------------ | - | `up` | A (single press) | Increment | - | `down` | B (single press) | Decrement | - | `home` | A (long press) | Reset to 0 | - | (handled by `Screen`)| B (long press) | Back to menu | + | Key (TUI) | Badge button | Action | + | --------- | ---------------- | ------------ | + | `up` | A (single press) | Increment | + | `down` | B (single press) | Decrement | + | `home` | A (long press) | Reset to 0 | + | — | B (long press) | Back to menu (handled by `NameBadge.Screen`) | The mapping from badge button to TUI key code is owned by - `NameBadge.Screen.ExRatatui`'s default key map. Switching this app - to e.g. left/right is a matter of passing a different `:key_map` in - the screen's mount args, not editing this module. + `NameBadge.Screen.ExRatatui`'s default key map; this app only cares + about the `code` strings. """ use ExRatatui.App alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect - alias ExRatatui.Widgets.Paragraph + alias ExRatatui.Style + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Paragraph} + + @reversed %Style{modifiers: [:reversed]} @impl ExRatatui.App def mount(_opts), do: {:ok, %{count: 0}} @impl ExRatatui.App def render(state, frame) do + block_rect = %Rect{x: 2, y: 1, width: frame.width - 4, height: 9} + + count_rect = %Rect{ + x: block_rect.x + 1, + y: block_rect.y + div(block_rect.height, 2), + width: block_rect.width - 2, + height: 1 + } + + hint_y = block_rect.y + block_rect.height + 2 + [ - {%Paragraph{text: "COUNTER", alignment: :center}, - %Rect{x: 0, y: 1, width: frame.width, height: 1}}, - {%Paragraph{text: "COUNT: #{state.count}", alignment: :center}, - %Rect{x: 0, y: div(frame.height, 2), width: frame.width, height: 1}}, - {%Paragraph{text: "A: +1 A LONG: RESET", alignment: :center}, - %Rect{x: 0, y: frame.height - 3, width: frame.width, height: 1}}, - {%Paragraph{text: "B: -1 B LONG: BACK", alignment: :center}, - %Rect{x: 0, y: frame.height - 2, width: frame.width, height: 1}} + {%Block{title: " counter ", borders: [:all]}, block_rect}, + {%Paragraph{text: "count: #{state.count}", alignment: :center}, count_rect}, + {hint_paragraph([ + {" A ", :reversed}, + {" +1 ", :plain}, + {" A long ", :reversed}, + {" reset", :plain} + ]), %Rect{x: 4, y: hint_y, width: frame.width - 8, height: 1}}, + {hint_paragraph([ + {" B ", :reversed}, + {" -1 ", :plain}, + {" B long ", :reversed}, + {" back", :plain} + ]), %Rect{x: 4, y: hint_y + 1, width: frame.width - 8, height: 1}} ] end @@ -49,4 +87,14 @@ defmodule NameBadge.Screen.ExRatatui.Counter do @impl ExRatatui.App def handle_info(_message, state), do: {:noreply, state} + + defp hint_paragraph(segments) do + spans = + Enum.map(segments, fn + {text, :reversed} -> %Span{content: text, style: @reversed} + {text, :plain} -> %Span{content: text} + end) + + %Paragraph{text: spans} + end end diff --git a/test/name_badge/ex_ratatui/font_test.exs b/test/name_badge/ex_ratatui/font_test.exs index 21d416d..c0dc594 100644 --- a/test/name_badge/ex_ratatui/font_test.exs +++ b/test/name_badge/ex_ratatui/font_test.exs @@ -81,7 +81,19 @@ defmodule NameBadge.ExRatatui.FontTest do end test "is true for the light single-line box-drawing set" do - box = [0x2500, 0x2502, 0x250C, 0x2510, 0x2514, 0x2518, 0x251C, 0x2524, 0x252C, 0x2534, 0x253C] + box = [ + 0x2500, + 0x2502, + 0x250C, + 0x2510, + 0x2514, + 0x2518, + 0x251C, + 0x2524, + 0x252C, + 0x2534, + 0x253C + ] for cp <- box do assert Font.has_glyph?(cp), "expected glyph for U+#{Integer.to_string(cp, 16)}" diff --git a/test/name_badge/screen/ex_ratatui/counter_test.exs b/test/name_badge/screen/ex_ratatui/counter_test.exs index a66b3c9..5ee2751 100644 --- a/test/name_badge/screen/ex_ratatui/counter_test.exs +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -3,7 +3,9 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect - alias ExRatatui.Widgets.Paragraph + alias ExRatatui.Style + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Paragraph} alias NameBadge.Screen.ExRatatui.Counter describe "mount/1" do @@ -36,29 +38,66 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do end describe "render/2" do - test "produces a header, count line, and two footer hints" do - widgets = Counter.render(%{count: 7}, %Rect{x: 0, y: 0, width: 66, height: 37}) + setup do + [widgets: Counter.render(%{count: 7}, %Rect{x: 0, y: 0, width: 66, height: 37})] + end + test "produces a bordered block, count display, and two hint lines", %{widgets: widgets} do assert length(widgets) == 4 - texts = - Enum.map(widgets, fn {%Paragraph{text: text}, _rect} -> text end) + [{block, _}, {count, _}, {a_hints, _}, {b_hints, _}] = widgets + + assert %Block{title: " counter ", borders: [:all]} = block + assert %Paragraph{text: "count: 7", alignment: :center} = count + assert %Paragraph{text: a_spans} = a_hints + assert %Paragraph{text: b_spans} = b_hints - assert "COUNTER" in texts - assert "COUNT: 7" in texts - assert "A: +1 A LONG: RESET" in texts - assert "B: -1 B LONG: BACK" in texts + assert is_list(a_spans) + assert is_list(b_spans) end - test "every widget is centered horizontally" do - widgets = Counter.render(%{count: 0}, %Rect{x: 0, y: 0, width: 66, height: 37}) + test "keys A, A long, B, B long render in reverse-video chips", %{widgets: widgets} do + [_block, _count, {%Paragraph{text: a_spans}, _}, {%Paragraph{text: b_spans}, _}] = widgets + + reversed = %Style{modifiers: [:reversed]} - for {%Paragraph{alignment: alignment}, _rect} <- widgets do - assert alignment == :center + # First and third spans on each hint line are the key chips — + # they must carry :reversed so the rasterer flips them to + # paper-on-ink. + assert %Span{content: " A ", style: ^reversed} = Enum.at(a_spans, 0) + assert %Span{content: " A long ", style: ^reversed} = Enum.at(a_spans, 2) + assert %Span{content: " B ", style: ^reversed} = Enum.at(b_spans, 0) + assert %Span{content: " B long ", style: ^reversed} = Enum.at(b_spans, 2) + + # Action labels are plain (no reversal). + for span_index <- [1, 3], spans <- [a_spans, b_spans] do + assert %Span{style: %Style{modifiers: []}} = Enum.at(spans, span_index) end end - test "rects fit within the frame" do + test "lowercase action labels exercise the new font glyphs", %{widgets: widgets} do + [ + _block, + {%Paragraph{text: count_text}, _}, + {%Paragraph{text: a_spans}, _}, + {%Paragraph{text: b_spans}, _} + ] = widgets + + # The whole string is lowercase except the literal A / B key + # labels — proves we're not falling back to all-uppercase + # because of font gaps. + assert count_text =~ "count:" + + labels = + (a_spans ++ b_spans) + |> Enum.map(& &1.content) + |> Enum.join("") + + assert labels =~ "reset" + assert labels =~ "back" + end + + test "every rect fits within the frame" do frame = %Rect{x: 0, y: 0, width: 66, height: 37} widgets = Counter.render(%{count: 0}, frame) diff --git a/test/name_badge/screen/ex_ratatui_test.exs b/test/name_badge/screen/ex_ratatui_test.exs index 3e9414b..1ea800b 100644 --- a/test/name_badge/screen/ex_ratatui_test.exs +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -40,8 +40,7 @@ defmodule NameBadge.Screen.ExRatatuiTest do @impl true def render(_state, frame) do [ - {%Paragraph{text: "HI"}, - %Rect{x: 0, y: 0, width: frame.width, height: frame.height}} + {%Paragraph{text: "HI"}, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}} ] end From 4fdd504e6c6483564cf4466ad558ca8386fd0a69 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 13:37:30 +0200 Subject: [PATCH 14/30] feat(screen): add Goatmire animated greeting demo --- lib/name_badge/screen/ex_ratatui/goatmire.ex | 138 +++++++++++++++ lib/name_badge/screen/goatmire.ex | 9 + .../screen/ex_ratatui/goatmire_test.exs | 162 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 lib/name_badge/screen/ex_ratatui/goatmire.ex create mode 100644 lib/name_badge/screen/goatmire.ex create mode 100644 test/name_badge/screen/ex_ratatui/goatmire_test.exs diff --git a/lib/name_badge/screen/ex_ratatui/goatmire.ex b/lib/name_badge/screen/ex_ratatui/goatmire.ex new file mode 100644 index 0000000..bc63c4d --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/goatmire.ex @@ -0,0 +1,138 @@ +defmodule NameBadge.Screen.ExRatatui.Goatmire do + @moduledoc """ + Animated Goatmire-themed greeting card — the canvas-and-subscriptions + showcase for the `NameBadge.Screen.ExRatatui` adapter. + + Draws a chunky 1-bit goat on a `Canvas` with the `:block` marker so + it works on the e-ink font (which has no braille glyphs), then wags + the tail by re-rendering on a 250 ms tick declared via + `ExRatatui.Subscription.interval/3`. Built on the reducer runtime — + one `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it + doubles as a tour of how to write a self-ticking ExRatatui app. + + ## Layout + + ┌─ hi from ex_ratatui ─────────────────────────┐ + │ │ + │ ╓─ goat goes here ─╖ │ + │ │ + └──────────────────────────────────────────────┘ + + [ A ] pause [ A long ] reset [ B long ] back + + ## Controls + + | Key (TUI) | Badge button | Action | + | --------- | ---------------- | -------------------- | + | `up` | A (single press) | Pause/resume the wag | + | `home` | A (long press) | Reset the tail to rest | + | — | B (long press) | Back to menu (handled by `NameBadge.Screen`) | + """ + + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Subscription + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Canvas, Paragraph} + alias ExRatatui.Widgets.Canvas.{Line, Points, Rectangle} + + @reversed %Style{modifiers: [:reversed]} + + # Tail swing parameters: ~33° amplitude around 135° (up-left), one + # full cycle per ~5.2 seconds at the 250 ms tick. + @tail_step 0.3 + @tail_amplitude :math.pi() / 5.5 + @tail_baseline :math.pi() * 3 / 4 + @tail_length 6.0 + + @impl ExRatatui.App + def init(_opts), do: {:ok, %{tick: 0, paused?: false}} + + @impl ExRatatui.App + def render(state, frame) do + canvas_rect = %Rect{x: 0, y: 0, width: frame.width, height: frame.height - 2} + hint_rect = %Rect{x: 2, y: frame.height - 1, width: frame.width - 4, height: 1} + + canvas = %Canvas{ + x_bounds: {-30.0, 30.0}, + y_bounds: {-10.0, 14.0}, + marker: :block, + shapes: goat_shapes(state), + block: %Block{title: " hi from ex_ratatui ", borders: [:all]} + } + + [ + {canvas, canvas_rect}, + {hint_paragraph(state), hint_rect} + ] + end + + @impl ExRatatui.App + def update({:event, %Key{code: "up"}}, state), + do: {:noreply, %{state | paused?: not state.paused?}} + + def update({:event, %Key{code: "home"}}, state), + do: {:noreply, %{state | tick: 0}} + + def update({:info, :tick}, %{paused?: true} = state), + do: {:noreply, state} + + def update({:info, :tick}, state), + do: {:noreply, %{state | tick: state.tick + 1}} + + def update(_msg, state), do: {:noreply, state} + + @impl ExRatatui.App + def subscriptions(_state) do + [Subscription.interval(:goat_tick, 250, :tick)] + end + + # Goat geometry, in canvas units. Head on the right, tail on the + # left, only the tail moves. Coordinates are mathematical (Y grows + # up), bottom-left anchoring on rectangles per the Canvas widget. + defp goat_shapes(state) do + body = %Rectangle{x: -12.0, y: -2.0, width: 14.0, height: 8.0, color: :white} + head = %Rectangle{x: 2.0, y: 2.0, width: 8.0, height: 7.0, color: :white} + + horns = [ + %Line{x1: 2.0, y1: 9.0, x2: 0.0, y2: 13.0, color: :white}, + %Line{x1: 10.0, y1: 9.0, x2: 12.0, y2: 13.0, color: :white} + ] + + eye = %Points{coords: [{8.0, 6.0}], color: :white} + beard = %Line{x1: 4.0, y1: 2.0, x2: 4.0, y2: -1.0, color: :white} + + legs = + for x <- [-10.0, -8.0, 0.0, -2.0] do + %Line{x1: x, y1: -2.0, x2: x, y2: -7.0, color: :white} + end + + [body, head, eye, beard, tail_shape(state) | horns ++ legs] + end + + defp tail_shape(state) do + angle = @tail_baseline + @tail_amplitude * :math.sin(state.tick * @tail_step) + {bx, by} = {-12.0, 5.0} + tx = bx + @tail_length * :math.cos(angle) + ty = by + @tail_length * :math.sin(angle) + %Line{x1: bx, y1: by, x2: tx, y2: ty, color: :white} + end + + defp hint_paragraph(state) do + pause_label = if state.paused?, do: " resume ", else: " pause " + + spans = [ + %Span{content: " A ", style: @reversed}, + %Span{content: pause_label}, + %Span{content: " A long ", style: @reversed}, + %Span{content: " reset "}, + %Span{content: " B long ", style: @reversed}, + %Span{content: " back"} + ] + + %Paragraph{text: spans} + end +end diff --git a/lib/name_badge/screen/goatmire.ex b/lib/name_badge/screen/goatmire.ex new file mode 100644 index 0000000..ce9bd8e --- /dev/null +++ b/lib/name_badge/screen/goatmire.ex @@ -0,0 +1,9 @@ +defmodule NameBadge.Screen.Goatmire do + @moduledoc """ + Menu-facing wrapper around `NameBadge.Screen.ExRatatui.Goatmire` — + hosts the animated greeting through `NameBadge.Screen.ExRatatui` + with the adapter's default A/B/A-long key map. + """ + + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Goatmire +end diff --git a/test/name_badge/screen/ex_ratatui/goatmire_test.exs b/test/name_badge/screen/ex_ratatui/goatmire_test.exs new file mode 100644 index 0000000..8b80c59 --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/goatmire_test.exs @@ -0,0 +1,162 @@ +defmodule NameBadge.Screen.ExRatatui.GoatmireTest do + use ExUnit.Case, async: true + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Subscription + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Canvas, Paragraph} + alias ExRatatui.Widgets.Canvas.{Line, Points, Rectangle} + alias NameBadge.Screen.ExRatatui.Goatmire + + describe "init/1" do + test "starts at tick 0 and unpaused" do + assert {:ok, %{tick: 0, paused?: false}} = Goatmire.init([]) + end + end + + describe "update/2 — events" do + test "A (up) toggles paused?" do + assert {:noreply, %{paused?: true}} = + Goatmire.update({:event, key("up")}, %{tick: 7, paused?: false}) + + assert {:noreply, %{paused?: false}} = + Goatmire.update({:event, key("up")}, %{tick: 7, paused?: true}) + end + + test "A long (home) snaps the tail back to rest by zeroing tick" do + assert {:noreply, %{tick: 0, paused?: false}} = + Goatmire.update({:event, key("home")}, %{tick: 99, paused?: false}) + + # Reset is independent of paused state. + assert {:noreply, %{tick: 0, paused?: true}} = + Goatmire.update({:event, key("home")}, %{tick: 99, paused?: true}) + end + + test "ignores unmapped keys" do + state = %{tick: 5, paused?: false} + assert {:noreply, ^state} = Goatmire.update({:event, key("down")}, state) + assert {:noreply, ^state} = Goatmire.update({:event, key("q")}, state) + end + end + + describe "update/2 — ticks" do + test "tick advances when not paused" do + assert {:noreply, %{tick: 1}} = + Goatmire.update({:info, :tick}, %{tick: 0, paused?: false}) + + assert {:noreply, %{tick: 43}} = + Goatmire.update({:info, :tick}, %{tick: 42, paused?: false}) + end + + test "tick is a no-op when paused" do + assert {:noreply, %{tick: 42}} = + Goatmire.update({:info, :tick}, %{tick: 42, paused?: true}) + end + + test "ignores unrelated info messages" do + state = %{tick: 1, paused?: false} + assert {:noreply, ^state} = Goatmire.update({:info, :unrelated}, state) + end + end + + describe "subscriptions/1" do + test "registers a 250 ms tick subscription with a stable id" do + assert [%Subscription{id: :goat_tick, kind: :interval, interval_ms: 250, message: :tick}] = + Goatmire.subscriptions(%{tick: 0, paused?: false}) + end + end + + describe "render/2" do + setup do + [widgets: Goatmire.render(%{tick: 4, paused?: false}, frame())] + end + + test "produces a bordered canvas plus a hint paragraph", %{widgets: widgets} do + assert length(widgets) == 2 + + [{canvas, canvas_rect}, {hint, hint_rect}] = widgets + + assert %Canvas{ + marker: :block, + block: %Block{title: " hi from ex_ratatui ", borders: [:all]} + } = canvas + + assert %Paragraph{text: spans} = hint + assert is_list(spans) + + # Canvas takes everything but the bottom hint row. + assert canvas_rect.height == frame().height - 2 + assert hint_rect.y == frame().height - 1 + end + + test "canvas carries the goat's static parts and the animated tail", %{widgets: widgets} do + [{%Canvas{shapes: shapes}, _}, _] = widgets + + assert %Rectangle{x: -12.0, width: 14.0} = + Enum.find(shapes, &match?(%Rectangle{x: -12.0}, &1)) + + assert %Rectangle{x: 2.0, width: 8.0} = Enum.find(shapes, &match?(%Rectangle{x: 2.0}, &1)) + + assert Enum.any?(shapes, &match?(%Points{coords: [{8.0, 6.0}]}, &1)) + + # 2 horns + 4 legs + 1 beard + 1 tail = 8 lines on the canvas. + lines = Enum.filter(shapes, &match?(%Line{}, &1)) + assert length(lines) == 8 + end + + test "tail tip moves between successive ticks (animation alive)" do + [{%Canvas{shapes: shapes_a}, _}, _] = Goatmire.render(%{tick: 0, paused?: false}, frame()) + [{%Canvas{shapes: shapes_b}, _}, _] = Goatmire.render(%{tick: 3, paused?: false}, frame()) + + tail_a = tail_line(shapes_a) + tail_b = tail_line(shapes_b) + + # Tail base is fixed; tip should differ across ticks because of + # the sin(tick * step) factor. + assert {tail_a.x1, tail_a.y1} == {-12.0, 5.0} + assert {tail_b.x1, tail_b.y1} == {-12.0, 5.0} + refute {tail_a.x2, tail_a.y2} == {tail_b.x2, tail_b.y2} + end + + test "hint reflects pause state" do + [_, {%Paragraph{text: spans_running}, _}] = + Goatmire.render(%{tick: 0, paused?: false}, frame()) + + [_, {%Paragraph{text: spans_paused}, _}] = + Goatmire.render(%{tick: 0, paused?: true}, frame()) + + assert spans_running |> Enum.map(& &1.content) |> Enum.join() =~ "pause" + assert spans_paused |> Enum.map(& &1.content) |> Enum.join() =~ "resume" + end + + test "key chips render in reverse-video", %{widgets: widgets} do + [_, {%Paragraph{text: spans}, _}] = widgets + + reversed = %Style{modifiers: [:reversed]} + assert %Span{content: " A ", style: ^reversed} = Enum.at(spans, 0) + assert %Span{content: " A long ", style: ^reversed} = Enum.at(spans, 2) + assert %Span{content: " B long ", style: ^reversed} = Enum.at(spans, 4) + end + + test "every rect fits within the frame" do + widgets = Goatmire.render(%{tick: 0, paused?: false}, frame()) + + for {_widget, rect} <- widgets do + assert rect.x + rect.width <= frame().width + assert rect.y + rect.height <= frame().height + end + end + end + + defp frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} + defp key(code), do: %Key{code: code, kind: "press", modifiers: []} + + defp tail_line(shapes) do + Enum.find(shapes, fn + %Line{x1: -12.0, y1: 5.0} -> true + _ -> false + end) + end +end From 13ab41e0c198a6e35e79e4466553adf73818badd Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 13:37:55 +0200 Subject: [PATCH 15/30] feat(screen): add Stats BEAM system monitor demo --- lib/name_badge/screen/ex_ratatui/stats.ex | 287 ++++++++++++++++++ lib/name_badge/screen/stats.ex | 9 + .../screen/ex_ratatui/stats_test.exs | 171 +++++++++++ 3 files changed, 467 insertions(+) create mode 100644 lib/name_badge/screen/ex_ratatui/stats.ex create mode 100644 lib/name_badge/screen/stats.ex create mode 100644 test/name_badge/screen/ex_ratatui/stats_test.exs diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex new file mode 100644 index 0000000..f0ccc3f --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -0,0 +1,287 @@ +defmodule NameBadge.Screen.ExRatatui.Stats do + @moduledoc """ + Live BEAM system monitor — the data-driven showcase for + `NameBadge.Screen.ExRatatui`. Refreshes once per second through an + interval subscription, keeps a rolling 60-sample history of memory + and reduction throughput, and renders both as `Sparkline`s using a + three-level bar set (`" "`, `"▄"`, `"█"`) that the badge font + ships glyphs for. + + Process metric is user-cyclable: reductions / memory / message + queue length. The metric drives the bottom "top processes" panel. + + ## Layout + + ┌─ system ────────────────────────────────────┐ + │ uptime: … procs: … │ + │ memory: … reds/s: … │ + │ memory ▁▂▃▅▇█▇▅▃▂▁ │ + │ reds/s ▂▅▃▇█▇▅▃▂▁▂ │ + │ ── top by reductions ── │ + │ <0.123.0> Logger.Backend 1.2M │ + └─────────────────────────────────────────────┘ + + [ A ] pause [ B ] cycle [ B long ] back + + ## Controls + + | Key (TUI) | Badge button | Action | + | --------- | ---------------- | -------------------- | + | `up` | A (single press) | Pause/resume refresh | + | `down` | B (single press) | Cycle metric | + | `home` | A (long press) | Reset histories | + | — | B (long press) | Back to menu | + """ + + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Subscription + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} + + @history_len 50 + @top_n 5 + @metrics [:reductions, :memory, :message_queue_len] + @bar_set [" ", "▄", "█"] + + @reversed %Style{modifiers: [:reversed]} + + @typedoc false + @type metric :: :reductions | :memory | :message_queue_len + + @typedoc false + @type sample :: %{ + uptime_ms: non_neg_integer(), + memory_kib: non_neg_integer(), + reds_delta: non_neg_integer(), + procs: non_neg_integer(), + top: [{pid(), String.t(), non_neg_integer()}] + } + + @typedoc false + @type state :: %{ + paused?: boolean(), + metric: metric(), + last_reductions: non_neg_integer() | nil, + memory_history: [non_neg_integer()], + reds_history: [non_neg_integer()], + sample: sample() | nil + } + + @impl ExRatatui.App + def init(_opts) do + {:ok, + %{ + paused?: false, + metric: :reductions, + last_reductions: nil, + memory_history: [], + reds_history: [], + sample: nil + } + |> refresh()} + end + + @impl ExRatatui.App + def render(state, frame) do + block_rect = %Rect{x: 0, y: 0, width: frame.width, height: frame.height - 2} + inner_x = block_rect.x + 2 + inner_w = block_rect.width - 4 + + sample = state.sample || empty_sample() + + rows = [ + {row(0), "uptime: #{format_uptime(sample.uptime_ms)} procs: #{sample.procs}"}, + {row(1), + "memory: #{format_kib(sample.memory_kib)} reds/s: #{format_count(sample.reds_delta)}"} + ] + + text_widgets = + for {y, text} <- rows do + {%Paragraph{text: text}, %Rect{x: inner_x, y: y, width: inner_w, height: 1}} + end + + sparkline_widgets = [ + {%Paragraph{text: "memory"}, %Rect{x: inner_x, y: row(3), width: 8, height: 1}}, + {%Sparkline{data: state.memory_history, bar_set: @bar_set, max: nil}, + %Rect{x: inner_x + 8, y: row(3), width: inner_w - 8, height: 1}}, + {%Paragraph{text: "reds/s"}, %Rect{x: inner_x, y: row(4), width: 8, height: 1}}, + {%Sparkline{data: state.reds_history, bar_set: @bar_set, max: nil}, + %Rect{x: inner_x + 8, y: row(4), width: inner_w - 8, height: 1}} + ] + + top_header = "── top by #{Atom.to_string(state.metric)} ──" + + top_lines = + sample.top + |> Enum.with_index() + |> Enum.map(fn {{pid, label, value}, idx} -> + {%Paragraph{text: format_top_line(pid, label, value, state.metric, inner_w)}, + %Rect{x: inner_x, y: row(7 + idx), width: inner_w, height: 1}} + end) + + [ + {%Block{title: " system ", borders: [:all]}, block_rect}, + {%Paragraph{text: top_header}, %Rect{x: inner_x, y: row(6), width: inner_w, height: 1}}, + {hint_paragraph(state), %Rect{x: 2, y: frame.height - 1, width: frame.width - 4, height: 1}} + ] ++ text_widgets ++ sparkline_widgets ++ top_lines + end + + @impl ExRatatui.App + def update({:event, %Key{code: "up"}}, state), + do: {:noreply, %{state | paused?: not state.paused?}} + + def update({:event, %Key{code: "down"}}, state), + do: {:noreply, %{state | metric: cycle_metric(state.metric)} |> refresh()} + + def update({:event, %Key{code: "home"}}, state) do + {:noreply, %{state | memory_history: [], reds_history: [], last_reductions: nil} |> refresh()} + end + + def update({:info, :refresh}, %{paused?: true} = state), do: {:noreply, state} + def update({:info, :refresh}, state), do: {:noreply, refresh(state)} + + def update(_msg, state), do: {:noreply, state} + + @impl ExRatatui.App + def subscriptions(_state) do + [Subscription.interval(:stats_refresh, 1_000, :refresh)] + end + + # Pure refresh: takes a state, samples the BEAM, returns the new state. + # Public-ish (defp) but the test reaches in to drive it deterministically. + @doc false + def refresh(state) do + {uptime_ms, _} = :erlang.statistics(:wall_clock) + memory_kib = div(:erlang.memory(:total), 1024) + {total_reds, _} = :erlang.statistics(:reductions) + + reds_delta = + case state.last_reductions do + nil -> 0 + prev -> max(total_reds - prev, 0) + end + + procs = length(Process.list()) + top = top_processes(state.metric) + + sample = %{ + uptime_ms: uptime_ms, + memory_kib: memory_kib, + reds_delta: reds_delta, + procs: procs, + top: top + } + + %{ + state + | last_reductions: total_reds, + memory_history: push_history(state.memory_history, memory_kib), + reds_history: push_history(state.reds_history, reds_delta), + sample: sample + } + end + + defp push_history(history, value) do + history + |> Enum.take(@history_len - 1) + |> List.insert_at(-1, value) + end + + defp cycle_metric(metric) do + idx = Enum.find_index(@metrics, &(&1 == metric)) || 0 + Enum.at(@metrics, rem(idx + 1, length(@metrics))) + end + + defp top_processes(metric) do + Process.list() + |> Enum.map(fn pid -> {pid, Process.info(pid, [metric, :registered_name, :initial_call])} end) + |> Enum.flat_map(fn + {pid, info} when is_list(info) -> [{pid, info}] + _ -> [] + end) + |> Enum.map(fn {pid, info} -> + value = Keyword.get(info, metric, 0) + label = process_label(info) + {pid, label, value} + end) + |> Enum.sort_by(fn {_pid, _label, value} -> value end, :desc) + |> Enum.take(@top_n) + end + + defp process_label(info) do + case Keyword.get(info, :registered_name) do + [] -> + case Keyword.get(info, :initial_call) do + {mod, fun, arity} -> "#{inspect(mod)}.#{fun}/#{arity}" + _ -> "—" + end + + name when is_atom(name) -> + Atom.to_string(name) + end + end + + defp format_top_line(pid, label, value, metric, width) do + pid_str = inspect(pid) + value_str = format_metric(metric, value) + pad = max(width - byte_size(pid_str) - byte_size(value_str) - 2, 1) + + truncated_label = String.slice(label, 0, pad) + pad_spaces = String.duplicate(" ", max(pad - byte_size(truncated_label), 1)) + + pid_str <> " " <> truncated_label <> pad_spaces <> value_str + end + + defp format_metric(:memory, bytes), do: format_kib(div(bytes, 1024)) + defp format_metric(_metric, value), do: format_count(value) + + defp format_uptime(ms) do + seconds = div(ms, 1000) + h = div(seconds, 3600) + m = div(rem(seconds, 3600), 60) + s = rem(seconds, 60) + + cond do + h > 0 -> "#{h}h #{m}m #{s}s" + m > 0 -> "#{m}m #{s}s" + true -> "#{s}s" + end + end + + defp format_kib(kib) when kib >= 1024, + do: :erlang.float_to_binary(kib / 1024, decimals: 1) <> " MiB" + + defp format_kib(kib), do: "#{kib} KiB" + + defp format_count(n) when n >= 1_000_000, + do: :erlang.float_to_binary(n / 1_000_000, decimals: 1) <> "M" + + defp format_count(n) when n >= 1_000, + do: :erlang.float_to_binary(n / 1_000, decimals: 1) <> "K" + + defp format_count(n), do: Integer.to_string(n) + + defp empty_sample, + do: %{uptime_ms: 0, memory_kib: 0, reds_delta: 0, procs: 0, top: []} + + defp row(n), do: 1 + n + + defp hint_paragraph(state) do + pause_label = if state.paused?, do: " resume ", else: " pause " + + spans = [ + %Span{content: " A ", style: @reversed}, + %Span{content: pause_label}, + %Span{content: " B ", style: @reversed}, + %Span{content: " cycle "}, + %Span{content: " B long ", style: @reversed}, + %Span{content: " back"} + ] + + %Paragraph{text: spans} + end +end diff --git a/lib/name_badge/screen/stats.ex b/lib/name_badge/screen/stats.ex new file mode 100644 index 0000000..e606c6c --- /dev/null +++ b/lib/name_badge/screen/stats.ex @@ -0,0 +1,9 @@ +defmodule NameBadge.Screen.Stats do + @moduledoc """ + Menu-facing wrapper around `NameBadge.Screen.ExRatatui.Stats` — + hosts the BEAM system monitor through `NameBadge.Screen.ExRatatui` + with the adapter's default A/B/A-long key map. + """ + + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Stats +end diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs new file mode 100644 index 0000000..642a25e --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -0,0 +1,171 @@ +defmodule NameBadge.Screen.ExRatatui.StatsTest do + use ExUnit.Case, async: true + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Subscription + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} + alias NameBadge.Screen.ExRatatui.Stats + + describe "init/1" do + test "starts unpaused, on :reductions, and seeds a sample" do + assert {:ok, state} = Stats.init([]) + assert state.paused? == false + assert state.metric == :reductions + assert is_map(state.sample) + assert is_list(state.memory_history) and length(state.memory_history) == 1 + assert is_list(state.reds_history) and length(state.reds_history) == 1 + end + end + + describe "update/2 — events" do + setup do + {:ok, state} = Stats.init([]) + [state: state] + end + + test "A (up) toggles pause", %{state: state} do + assert {:noreply, %{paused?: true}} = Stats.update({:event, key("up")}, state) + + assert {:noreply, %{paused?: false}} = + Stats.update({:event, key("up")}, %{state | paused?: true}) + end + + test "B (down) cycles metric and re-samples", %{state: state} do + {:noreply, %{metric: m1}} = Stats.update({:event, key("down")}, state) + {:noreply, %{metric: m2}} = Stats.update({:event, key("down")}, %{state | metric: m1}) + {:noreply, %{metric: m3}} = Stats.update({:event, key("down")}, %{state | metric: m2}) + + assert m1 == :memory + assert m2 == :message_queue_len + assert m3 == :reductions + end + + test "home (A long) clears histories and re-seeds with one fresh sample", %{state: state} do + state = %{ + state + | memory_history: [10, 20, 30], + reds_history: [1, 2, 3], + last_reductions: 999_999 + } + + {:noreply, after_reset} = Stats.update({:event, key("home")}, state) + + assert length(after_reset.memory_history) == 1 + assert length(after_reset.reds_history) == 1 + # last_reductions was reset to nil before refresh ran, so the + # delta on the seeded sample is zero. + assert hd(after_reset.reds_history) == 0 + end + + test "ignores unmapped keys", %{state: state} do + assert {:noreply, ^state} = Stats.update({:event, key("left")}, state) + end + end + + describe "update/2 — refresh ticks" do + setup do + {:ok, state} = Stats.init([]) + [state: state] + end + + test "appends a new sample to both histories", %{state: state} do + assert {:noreply, after_tick} = Stats.update({:info, :refresh}, state) + + assert length(after_tick.memory_history) == length(state.memory_history) + 1 + assert length(after_tick.reds_history) == length(state.reds_history) + 1 + end + + test "is a no-op when paused", %{state: state} do + paused = %{state | paused?: true} + assert {:noreply, ^paused} = Stats.update({:info, :refresh}, paused) + end + + test "history is capped at 50 samples", %{state: state} do + saturated = + Enum.reduce(1..60, state, fn _, acc -> + {:noreply, next} = Stats.update({:info, :refresh}, acc) + next + end) + + assert length(saturated.memory_history) == 50 + assert length(saturated.reds_history) == 50 + end + + test "ignores unrelated info messages", %{state: state} do + assert {:noreply, ^state} = Stats.update({:info, :nope}, state) + end + end + + describe "subscriptions/1" do + test "registers a 1 s refresh subscription with a stable id" do + assert [ + %Subscription{ + id: :stats_refresh, + kind: :interval, + interval_ms: 1_000, + message: :refresh + } + ] = Stats.subscriptions(%{}) + end + end + + describe "render/2" do + setup do + {:ok, state} = Stats.init([]) + [state: state, widgets: Stats.render(state, frame())] + end + + test "produces the bordered system block, two stat lines, two sparklines, top header, hint, and top rows", + %{widgets: widgets} do + # 1 block + 1 top-header + 1 hint + 2 stat rows + 4 sparkline-related (2 labels + 2 charts) + N top rows. + block = Enum.find(widgets, &match?({%Block{}, _}, &1)) + assert {%Block{title: " system ", borders: [:all]}, _} = block + + sparklines = Enum.filter(widgets, &match?({%Sparkline{}, _}, &1)) + assert length(sparklines) == 2 + + for {%Sparkline{bar_set: bar_set}, _} <- sparklines do + assert bar_set == [" ", "▄", "█"] + end + end + + test "hint shows the cycle action and pause/resume label flips", %{state: state} do + [_, {%Paragraph{text: spans_running}, _}] = + widgets_at_hint(Stats.render(state, frame())) + + [_, {%Paragraph{text: spans_paused}, _}] = + widgets_at_hint(Stats.render(%{state | paused?: true}, frame())) + + assert spans_running |> Enum.map(& &1.content) |> Enum.join() =~ "pause" + assert spans_paused |> Enum.map(& &1.content) |> Enum.join() =~ "resume" + + reversed = %Style{modifiers: [:reversed]} + assert %Span{content: " A ", style: ^reversed} = Enum.at(spans_running, 0) + assert %Span{content: " B ", style: ^reversed} = Enum.at(spans_running, 2) + assert %Span{content: " B long ", style: ^reversed} = Enum.at(spans_running, 4) + end + + test "every rect fits within the frame", %{state: state} do + widgets = Stats.render(state, frame()) + + for {_widget, rect} <- widgets do + assert rect.x + rect.width <= frame().width + assert rect.y + rect.height <= frame().height + end + end + end + + defp frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} + defp key(code), do: %Key{code: code, kind: "press", modifiers: []} + + # The hint Paragraph is the one whose rect.y == frame.height - 1. + defp widgets_at_hint(widgets) do + {hint_w, hint_r} = + Enum.find(widgets, fn {_widget, %Rect{y: y}} -> y == frame().height - 1 end) + + [{:hint_marker, nil}, {hint_w, hint_r}] + end +end From 075cd45d9d1d1d0b0bdad7b822d779d3fe48ec7c Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 13:38:12 +0200 Subject: [PATCH 16/30] feat(screen): register Goatmire and Stats in the top-level menu --- lib/name_badge/screen/top_level.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/name_badge/screen/top_level.ex b/lib/name_badge/screen/top_level.ex index 3d818d9..4dd072b 100644 --- a/lib/name_badge/screen/top_level.ex +++ b/lib/name_badge/screen/top_level.ex @@ -8,14 +8,17 @@ defmodule NameBadge.Screen.TopLevel do {Screen.Gallery, "Gallery"}, {Screen.Snake, "Snake"}, {Screen.Counter, "Counter"}, + {Screen.Goatmire, "Goatmire"}, + {Screen.Stats, "Stats"}, {Screen.Weather, "Weather"}, {Screen.Settings, "Device Settings"} ] defp screens do if NameBadge.CalendarService.enabled?() do - # Insert Calendar after Weather - List.insert_at(@base_screens, 4, {Screen.Calendar, "Calendar"}) + # Insert Calendar just before Weather + weather_index = Enum.find_index(@base_screens, &match?({Screen.Weather, _}, &1)) + List.insert_at(@base_screens, weather_index, {Screen.Calendar, "Calendar"}) else @base_screens end From f153b7b4a1284e231b6ca43bc5e8da73f71241ae Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 13:52:47 +0200 Subject: [PATCH 17/30] fix(ex_ratatui): invert cells only on bg :black or :reversed modifier --- lib/name_badge/ex_ratatui/raster.ex | 37 ++++++++++++---------- test/name_badge/ex_ratatui/raster_test.exs | 23 +++++++++++--- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/lib/name_badge/ex_ratatui/raster.ex b/lib/name_badge/ex_ratatui/raster.ex index 8c17a97..0a1f2c6 100644 --- a/lib/name_badge/ex_ratatui/raster.ex +++ b/lib/name_badge/ex_ratatui/raster.ex @@ -27,18 +27,19 @@ defmodule NameBadge.ExRatatui.Raster do ## Style support - The 1-bit display has no concept of color, so any non-default - `bg` color or the `:reversed` modifier collapses to "this cell is - inverted": the glyph paints as paper on an ink background, instead - of ink on paper. The `fg` color is otherwise ignored — there is - only ink. Other modifiers (bold, italic, underlined, …) are - ignored. `:skip` cells render as paper. - - | Cell shape | Pixels | - | ----------------------------------------------- | ------------- | - | `bg: :reset`, no `:reversed` | ink-on-paper | - | `bg: `, or `:reversed` in mods| paper-on-ink | - | `:skip: true` | all paper | + The 1-bit display has no concept of color, so inversion has to be + asked for explicitly. A cell paints paper-on-ink only when the + `:reversed` modifier is set or `bg` is `:black`; everything else — + including `bg: :white` (which the `Canvas` widget emits by default + for shape cells) and any other ANSI color — paints ink-on-paper. + The `fg` color is ignored entirely. Other modifiers (bold, italic, + underlined, …) are ignored too. `:skip` cells render as paper. + + | Cell shape | Pixels | + | ------------------------------------------------ | ------------- | + | `:reversed` in modifiers, or `bg: :black` | paper-on-ink | + | anything else | ink-on-paper | + | `:skip: true` | all paper | Bold-as-double-strike, underline-as-bottom-row, and grayscale `fg` / `bg` mappings are deferred until a demo needs them. @@ -168,9 +169,11 @@ defmodule NameBadge.ExRatatui.Raster do # Returns the {ink_byte, paper_byte} pair to use for a cell. On the # 1-bit e-ink display, "color" collapses to "are we inverted?" — a - # cell with a non-default bg or the `:reversed` modifier paints - # paper glyphs on an ink background; everything else paints the - # other way around. + # cell paints paper glyphs on an ink background only when the user + # explicitly asked for it via the `:reversed` modifier or + # `bg: :black`. Other bg colors (notably `:white`, which the Canvas + # widget emits as its default fill) leave the cell rendering + # ink-on-paper. defp ink_and_paper(%Cell{bg: bg, modifiers: modifiers}) do if inverted?(bg, modifiers) do {@paper, @ink} @@ -179,8 +182,8 @@ defmodule NameBadge.ExRatatui.Raster do end end - defp inverted?(:reset, modifiers), do: :reversed in modifiers - defp inverted?(_bg, _modifiers), do: true + defp inverted?(:black, _modifiers), do: true + defp inverted?(_bg, modifiers), do: :reversed in modifiers defp codepoint_of(""), do: ?\s diff --git a/test/name_badge/ex_ratatui/raster_test.exs b/test/name_badge/ex_ratatui/raster_test.exs index 8d10c6f..d5da6ae 100644 --- a/test/name_badge/ex_ratatui/raster_test.exs +++ b/test/name_badge/ex_ratatui/raster_test.exs @@ -83,11 +83,8 @@ defmodule NameBadge.ExRatatui.RasterTest do end describe "style: inversion" do - test "a cell with a non-default bg paints paper-on-ink (filling the whole cell rect)" do - inverted = %Cell{ - cell(0, 0, "A") - | bg: :white - } + test "a cell with `bg: :black` paints paper-on-ink (filling the whole cell rect)" do + inverted = %Cell{cell(0, 0, "A") | bg: :black} bin = Raster.new() @@ -123,6 +120,22 @@ defmodule NameBadge.ExRatatui.RasterTest do assert byte_at(bin, 5, 0) == 0 end + test "`bg: :white` does NOT invert — the Canvas widget emits this for shape cells" do + # On a 1-bit display, white is paper, not "a colored bg". This + # guards the regression that used to hide every Canvas shape on + # the badge: shapes painted as `█` came in with `bg: :white` and + # the rasterer flipped them to paper-on-ink, drawing nothing. + cell_white_bg = %Cell{cell(0, 0, "A") | bg: :white} + + bin = + Raster.new() + |> Raster.put_snapshot(snapshot([cell_white_bg])) + |> Raster.to_grayscale() + + assert byte_at(bin, 1, 0) == 0 + assert byte_at(bin, 5, 0) == 255 + end + test "default styling is unchanged (still ink-on-paper)" do bin = Raster.new() From 24ea68115b5f8d0e0a133ecf0e23f7506d82ae14 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 13:53:04 +0200 Subject: [PATCH 18/30] feat(screen): brand demos with ex_ratatui on top border --- lib/name_badge/screen/ex_ratatui/counter.ex | 2 +- lib/name_badge/screen/ex_ratatui/goatmire.ex | 20 +++++++++++++------ lib/name_badge/screen/ex_ratatui/stats.ex | 19 +++++++++++------- .../screen/ex_ratatui/counter_test.exs | 2 +- .../screen/ex_ratatui/goatmire_test.exs | 18 +++++++++++++---- .../screen/ex_ratatui/stats_test.exs | 11 +++++++--- 6 files changed, 50 insertions(+), 22 deletions(-) diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index 8453cbd..50fb7ae 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -62,7 +62,7 @@ defmodule NameBadge.Screen.ExRatatui.Counter do hint_y = block_rect.y + block_rect.height + 2 [ - {%Block{title: " counter ", borders: [:all]}, block_rect}, + {%Block{title: " ex_ratatui · counter ", borders: [:all]}, block_rect}, {%Paragraph{text: "count: #{state.count}", alignment: :center}, count_rect}, {hint_paragraph([ {" A ", :reversed}, diff --git a/lib/name_badge/screen/ex_ratatui/goatmire.ex b/lib/name_badge/screen/ex_ratatui/goatmire.ex index bc63c4d..38dc4f6 100644 --- a/lib/name_badge/screen/ex_ratatui/goatmire.ex +++ b/lib/name_badge/screen/ex_ratatui/goatmire.ex @@ -5,11 +5,16 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do Draws a chunky 1-bit goat on a `Canvas` with the `:block` marker so it works on the e-ink font (which has no braille glyphs), then wags - the tail by re-rendering on a 250 ms tick declared via + the tail on a 1 s tick declared via `ExRatatui.Subscription.interval/3`. Built on the reducer runtime — one `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it doubles as a tour of how to write a self-ticking ExRatatui app. + The 1 s tick is tuned for the badge's UC8276 partial-refresh budget + (≈ 350 ms per refresh). On the simulator this looks slower than a + desktop animation would; that's deliberate, the app should look the + same place the firmware ends up running. + ## Layout ┌─ hi from ex_ratatui ─────────────────────────┐ @@ -41,12 +46,15 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do @reversed %Style{modifiers: [:reversed]} - # Tail swing parameters: ~33° amplitude around 135° (up-left), one - # full cycle per ~5.2 seconds at the 250 ms tick. - @tail_step 0.3 + # Tail swing parameters: ~33° amplitude around 135° (up-left). With + # the 1 s tick and a 0.5 rad/tick advance, each full wag cycle takes + # ~12.5 s — slow enough for the e-ink partial refresh to keep up, + # fast enough that the user sees the tail move every second. + @tail_step 0.5 @tail_amplitude :math.pi() / 5.5 @tail_baseline :math.pi() * 3 / 4 @tail_length 6.0 + @tick_interval_ms 1_000 @impl ExRatatui.App def init(_opts), do: {:ok, %{tick: 0, paused?: false}} @@ -61,7 +69,7 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do y_bounds: {-10.0, 14.0}, marker: :block, shapes: goat_shapes(state), - block: %Block{title: " hi from ex_ratatui ", borders: [:all]} + block: %Block{title: " ex_ratatui · goatmire ", borders: [:all]} } [ @@ -87,7 +95,7 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do @impl ExRatatui.App def subscriptions(_state) do - [Subscription.interval(:goat_tick, 250, :tick)] + [Subscription.interval(:goat_tick, @tick_interval_ms, :tick)] end # Goat geometry, in canvas units. Head on the right, tail on the diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index f0ccc3f..22a9f3a 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -1,11 +1,15 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @moduledoc """ Live BEAM system monitor — the data-driven showcase for - `NameBadge.Screen.ExRatatui`. Refreshes once per second through an - interval subscription, keeps a rolling 60-sample history of memory - and reduction throughput, and renders both as `Sparkline`s using a - three-level bar set (`" "`, `"▄"`, `"█"`) that the badge font - ships glyphs for. + `NameBadge.Screen.ExRatatui`. Refreshes every 3 seconds through an + interval subscription, keeps a rolling 50-sample history of memory + and reduction throughput (≈ 2.5 minutes), and renders both as + `Sparkline`s using a three-level bar set (`" "`, `"▄"`, `"█"`) that + the badge font ships glyphs for. + + The 3 s cadence is tuned for the badge's UC8276 partial-refresh + budget — every tick produces a new frame for memory, reductions, + and the top-N panel, and at 3 s the panel keeps up without queueing. Process metric is user-cyclable: reductions / memory / message queue length. The metric drives the bottom "top processes" panel. @@ -46,6 +50,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @top_n 5 @metrics [:reductions, :memory, :message_queue_len] @bar_set [" ", "▄", "█"] + @refresh_interval_ms 3_000 @reversed %Style{modifiers: [:reversed]} @@ -124,7 +129,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do end) [ - {%Block{title: " system ", borders: [:all]}, block_rect}, + {%Block{title: " ex_ratatui · stats ", borders: [:all]}, block_rect}, {%Paragraph{text: top_header}, %Rect{x: inner_x, y: row(6), width: inner_w, height: 1}}, {hint_paragraph(state), %Rect{x: 2, y: frame.height - 1, width: frame.width - 4, height: 1}} ] ++ text_widgets ++ sparkline_widgets ++ top_lines @@ -148,7 +153,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @impl ExRatatui.App def subscriptions(_state) do - [Subscription.interval(:stats_refresh, 1_000, :refresh)] + [Subscription.interval(:stats_refresh, @refresh_interval_ms, :refresh)] end # Pure refresh: takes a state, samples the BEAM, returns the new state. diff --git a/test/name_badge/screen/ex_ratatui/counter_test.exs b/test/name_badge/screen/ex_ratatui/counter_test.exs index 5ee2751..404b7b2 100644 --- a/test/name_badge/screen/ex_ratatui/counter_test.exs +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -47,7 +47,7 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do [{block, _}, {count, _}, {a_hints, _}, {b_hints, _}] = widgets - assert %Block{title: " counter ", borders: [:all]} = block + assert %Block{title: " ex_ratatui · counter ", borders: [:all]} = block assert %Paragraph{text: "count: 7", alignment: :center} = count assert %Paragraph{text: a_spans} = a_hints assert %Paragraph{text: b_spans} = b_hints diff --git a/test/name_badge/screen/ex_ratatui/goatmire_test.exs b/test/name_badge/screen/ex_ratatui/goatmire_test.exs index 8b80c59..51d123e 100644 --- a/test/name_badge/screen/ex_ratatui/goatmire_test.exs +++ b/test/name_badge/screen/ex_ratatui/goatmire_test.exs @@ -62,9 +62,19 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do end describe "subscriptions/1" do - test "registers a 250 ms tick subscription with a stable id" do - assert [%Subscription{id: :goat_tick, kind: :interval, interval_ms: 250, message: :tick}] = - Goatmire.subscriptions(%{tick: 0, paused?: false}) + test "registers a hardware-friendly tick subscription with a stable id" do + assert [ + %Subscription{ + id: :goat_tick, + kind: :interval, + interval_ms: interval, + message: :tick + } + ] = Goatmire.subscriptions(%{tick: 0, paused?: false}) + + # Tick must clear the badge's UC8276 partial-refresh budget + # (≈ 350 ms) with margin so frames don't queue on hardware. + assert interval >= 700 end end @@ -80,7 +90,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do assert %Canvas{ marker: :block, - block: %Block{title: " hi from ex_ratatui ", borders: [:all]} + block: %Block{title: " ex_ratatui · goatmire ", borders: [:all]} } = canvas assert %Paragraph{text: spans} = hint diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index 642a25e..8fd9cae 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -100,15 +100,20 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do end describe "subscriptions/1" do - test "registers a 1 s refresh subscription with a stable id" do + test "registers a hardware-friendly refresh subscription with a stable id" do assert [ %Subscription{ id: :stats_refresh, kind: :interval, - interval_ms: 1_000, + interval_ms: interval, message: :refresh } ] = Stats.subscriptions(%{}) + + # Refresh must clear the badge's UC8276 partial-refresh budget + # (≈ 350 ms) with comfortable margin since each tick repaints + # the sparklines and the top-N panel. + assert interval >= 1_000 end end @@ -122,7 +127,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do %{widgets: widgets} do # 1 block + 1 top-header + 1 hint + 2 stat rows + 4 sparkline-related (2 labels + 2 charts) + N top rows. block = Enum.find(widgets, &match?({%Block{}, _}, &1)) - assert {%Block{title: " system ", borders: [:all]}, _} = block + assert {%Block{title: " ex_ratatui · stats ", borders: [:all]}, _} = block sparklines = Enum.filter(widgets, &match?({%Sparkline{}, _}, &1)) assert length(sparklines) == 2 From c0079e219be86470940795683d8250f779e99e90 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola Date: Sun, 10 May 2026 14:49:57 +0200 Subject: [PATCH 19/30] feat(ex_ratatui): add DemoFrame helper for shared screen chrome --- lib/name_badge/ex_ratatui/demo_frame.ex | 131 ++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 lib/name_badge/ex_ratatui/demo_frame.ex diff --git a/lib/name_badge/ex_ratatui/demo_frame.ex b/lib/name_badge/ex_ratatui/demo_frame.ex new file mode 100644 index 0000000..12c59a1 --- /dev/null +++ b/lib/name_badge/ex_ratatui/demo_frame.ex @@ -0,0 +1,131 @@ +defmodule NameBadge.ExRatatui.DemoFrame do + @moduledoc """ + Shared chrome for the badge's `ExRatatui.App` demos. + + Every demo lays out the same way: an outer `Block` titled + ` ex_ratatui · ` covers all but the bottom row, and a + single-row hint strip at the very bottom carries reverse-video + key chips next to plain action labels. Centralising those rects + here keeps the screens visually consistent and means individual + demos only have to position their own content. + + ## Usage + + {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("counter", frame) + + [ + {block, block_rect}, + {my_content_widget, content_rect}, + {DemoFrame.hint([ + {" A ", :chip}, {" +1 ", :label}, + {" A long ", :chip}, {" reset", :label} + ]), hint_rect} + ] + """ + + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, Paragraph} + + @chip_style %Style{modifiers: [:reversed]} + + @typedoc "Hint segments — `:chip` for reverse-video keys, `:label` for plain action text." + @type hint_segment :: {String.t(), :chip | :label} + + @doc """ + Builds the demo chrome for a frame. Returns + `{block_widget, block_rect, content_rect, hint_rect}`: + + * `block_widget` — the outer `%Block{}` titled + ` ex_ratatui · ` with full borders. + * `block_rect` — the outer rect that pairs with `block_widget`. + Spans `frame.width` × `frame.height - 2` so the bottom row + stays free for the hint strip. + * `content_rect` — the inner rect inside the block borders + (`block_rect` shrunk by 1 cell on each side). Use this to + position content widgets. + * `hint_rect` — the bottom-most row, padded 2 cells in from each + side, sized for a single-line `Paragraph`. + """ + @typedoc "Anything carrying `width` and `height` — both `%Rect{}` and `%ExRatatui.Frame{}` qualify." + @type sized :: %{ + :width => non_neg_integer(), + :height => non_neg_integer(), + optional(any()) => any() + } + + @spec layout(String.t(), sized()) :: {Block.t(), Rect.t(), Rect.t(), Rect.t()} + def layout(title, %{width: width, height: height}) + when is_binary(title) and is_integer(width) and is_integer(height) do + block_rect = %Rect{x: 0, y: 0, width: width, height: height - 2} + + content_rect = %Rect{ + x: block_rect.x + 1, + y: block_rect.y + 1, + width: block_rect.width - 2, + height: block_rect.height - 2 + } + + hint_rect = %Rect{ + x: 2, + y: height - 1, + width: width - 4, + height: 1 + } + + {title_block(title), block_rect, content_rect, hint_rect} + end + + @doc """ + Returns the standard outer block on its own — the same `%Block{}` + `layout/2` ships back in the tuple. Useful when the demo's main + widget is a `Canvas`/`Sparkline`/etc that takes its own `:block` + field instead of pairing the block with a separate rect. + """ + @spec title_block(String.t()) :: Block.t() + def title_block(title) when is_binary(title) do + %Block{title: " ex_ratatui · #{title} ", borders: [:all]} + end + + @doc """ + Returns a `%Rect{}` of `height` rows vertically centered inside + `parent`, full-width within it. Use this when a demo wants to + drop a content row (or a small block) in the middle of the + content area instead of stacking it at the top. + """ + @spec center_row(Rect.t(), pos_integer()) :: Rect.t() + def center_row(%Rect{} = parent, height) when height >= 1 and height <= parent.height do + y_offset = div(parent.height - height, 2) + + %Rect{ + x: parent.x, + y: parent.y + y_offset, + width: parent.width, + height: height + } + end + + @doc """ + Builds the standard hint `%Paragraph{}` from a list of segments. + + Segments are tuples of `{text, kind}`: + + * `{text, :chip}` — reverse-video key chip (e.g. `" A "`). + * `{text, :label}` — plain action label (e.g. `" pause"`). + """ + @spec hint([hint_segment()]) :: Paragraph.t() + def hint(segments) when is_list(segments) do + spans = + Enum.map(segments, fn + {text, :chip} -> %Span{content: text, style: @chip_style} + {text, :label} -> %Span{content: text} + end) + + %Paragraph{text: spans} + end + + @doc "The reverse-video `%Style{}` used for chip spans. Exposed for tests." + @spec chip_style() :: Style.t() + def chip_style(), do: @chip_style +end From e8b3b77bea054de1d94ea4739f06ad93416241b2 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 14:50:27 +0200 Subject: [PATCH 20/30] refactor(screen): use DemoFrame across Counter and Stats --- lib/name_badge/screen/ex_ratatui/counter.ex | 85 ++++--------- lib/name_badge/screen/ex_ratatui/stats.ex | 116 ++++++++++-------- .../screen/ex_ratatui/counter_test.exs | 79 +++++------- 3 files changed, 122 insertions(+), 158 deletions(-) diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index 50fb7ae..c44a0a7 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -1,25 +1,20 @@ defmodule NameBadge.Screen.ExRatatui.Counter do @moduledoc """ - A two-button TUI counter — the first end-to-end demo of the - `NameBadge.Screen.ExRatatui` adapter and the showcase that exercises - the rasterer's reverse-video support along with the font's - lowercase + box-drawing coverage. + A two-button TUI counter — the simplest end-to-end demo of the + `NameBadge.Screen.ExRatatui` adapter, sharing chrome with every + other ExRatatui demo through `NameBadge.ExRatatui.DemoFrame`. ## Layout - ┌─ counter ────────────────────────────────┐ - │ │ - │ count: 42 │ - │ │ - └──────────────────────────────────────────┘ + ┌─ ex_ratatui · counter ───────────────────────┐ + │ │ + │ │ + │ count: 42 │ + │ │ + │ │ + └──────────────────────────────────────────────┘ - [ A ] +1 [ A long ] reset - [ B ] -1 [ B long ] back - - The bracketed key labels render in reverse-video — paper glyphs on - an ink background — so the user can see at a glance which inputs - the screen responds to. The Block border, title, and lowercase - text all exercise font + raster paths the original Counter didn't. + [ A ] +1 [ A long ] reset [ B ] -1 [ B long ] back ## Controls @@ -29,53 +24,35 @@ defmodule NameBadge.Screen.ExRatatui.Counter do | `down` | B (single press) | Decrement | | `home` | A (long press) | Reset to 0 | | — | B (long press) | Back to menu (handled by `NameBadge.Screen`) | - - The mapping from badge button to TUI key code is owned by - `NameBadge.Screen.ExRatatui`'s default key map; this app only cares - about the `code` strings. """ use ExRatatui.App alias ExRatatui.Event.Key - alias ExRatatui.Layout.Rect - alias ExRatatui.Style - alias ExRatatui.Text.Span - alias ExRatatui.Widgets.{Block, Paragraph} - - @reversed %Style{modifiers: [:reversed]} + alias ExRatatui.Widgets.Paragraph + alias NameBadge.ExRatatui.DemoFrame @impl ExRatatui.App def mount(_opts), do: {:ok, %{count: 0}} @impl ExRatatui.App def render(state, frame) do - block_rect = %Rect{x: 2, y: 1, width: frame.width - 4, height: 9} - - count_rect = %Rect{ - x: block_rect.x + 1, - y: block_rect.y + div(block_rect.height, 2), - width: block_rect.width - 2, - height: 1 - } - - hint_y = block_rect.y + block_rect.height + 2 + {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("counter", frame) + count_rect = DemoFrame.center_row(content_rect, 1) [ - {%Block{title: " ex_ratatui · counter ", borders: [:all]}, block_rect}, + {block, block_rect}, {%Paragraph{text: "count: #{state.count}", alignment: :center}, count_rect}, - {hint_paragraph([ - {" A ", :reversed}, - {" +1 ", :plain}, - {" A long ", :reversed}, - {" reset", :plain} - ]), %Rect{x: 4, y: hint_y, width: frame.width - 8, height: 1}}, - {hint_paragraph([ - {" B ", :reversed}, - {" -1 ", :plain}, - {" B long ", :reversed}, - {" back", :plain} - ]), %Rect{x: 4, y: hint_y + 1, width: frame.width - 8, height: 1}} + {DemoFrame.hint([ + {" A ", :chip}, + {" +1 ", :label}, + {" A long ", :chip}, + {" reset ", :label}, + {" B ", :chip}, + {" -1 ", :label}, + {" B long ", :chip}, + {" back", :label} + ]), hint_rect} ] end @@ -87,14 +64,4 @@ defmodule NameBadge.Screen.ExRatatui.Counter do @impl ExRatatui.App def handle_info(_message, state), do: {:noreply, state} - - defp hint_paragraph(segments) do - spans = - Enum.map(segments, fn - {text, :reversed} -> %Span{content: text, style: @reversed} - {text, :plain} -> %Span{content: text} - end) - - %Paragraph{text: spans} - end end diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index 22a9f3a..c2fa469 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -41,19 +41,16 @@ defmodule NameBadge.Screen.ExRatatui.Stats do alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect - alias ExRatatui.Style alias ExRatatui.Subscription - alias ExRatatui.Text.Span - alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} + alias ExRatatui.Widgets.{Paragraph, Sparkline} + alias NameBadge.ExRatatui.DemoFrame @history_len 50 - @top_n 5 + @top_n 10 @metrics [:reductions, :memory, :message_queue_len] @bar_set [" ", "▄", "█"] @refresh_interval_ms 3_000 - @reversed %Style{modifiers: [:reversed]} - @typedoc false @type metric :: :reductions | :memory | :message_queue_len @@ -92,47 +89,66 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @impl ExRatatui.App def render(state, frame) do - block_rect = %Rect{x: 0, y: 0, width: frame.width, height: frame.height - 2} - inner_x = block_rect.x + 2 - inner_w = block_rect.width - 4 - + {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("stats", frame) sample = state.sample || empty_sample() - rows = [ - {row(0), "uptime: #{format_uptime(sample.uptime_ms)} procs: #{sample.procs}"}, - {row(1), - "memory: #{format_kib(sample.memory_kib)} reds/s: #{format_count(sample.reds_delta)}"} + # Lay the content out on a row grid that breathes — pad blank + # rows between sections so the screen doesn't bunch at the top. + inner_x = content_rect.x + 1 + inner_w = content_rect.width - 2 + label_w = 8 + + summary = [ + {0, "uptime: #{format_uptime(sample.uptime_ms)} procs: #{sample.procs}"}, + {1, + "memory: #{format_kib(sample.memory_kib)} reds/s: #{format_count(sample.reds_delta)}"} ] - text_widgets = - for {y, text} <- rows do - {%Paragraph{text: text}, %Rect{x: inner_x, y: y, width: inner_w, height: 1}} + summary_widgets = + for {dy, text} <- summary do + {%Paragraph{text: text}, + %Rect{x: inner_x, y: content_rect.y + dy, width: inner_w, height: 1}} end + sparkline_y = content_rect.y + 3 + sparkline_widgets = [ - {%Paragraph{text: "memory"}, %Rect{x: inner_x, y: row(3), width: 8, height: 1}}, - {%Sparkline{data: state.memory_history, bar_set: @bar_set, max: nil}, - %Rect{x: inner_x + 8, y: row(3), width: inner_w - 8, height: 1}}, - {%Paragraph{text: "reds/s"}, %Rect{x: inner_x, y: row(4), width: 8, height: 1}}, - {%Sparkline{data: state.reds_history, bar_set: @bar_set, max: nil}, - %Rect{x: inner_x + 8, y: row(4), width: inner_w - 8, height: 1}} + {%Paragraph{text: "memory"}, %Rect{x: inner_x, y: sparkline_y, width: label_w, height: 1}}, + {%Sparkline{data: state.memory_history, bar_set: @bar_set}, + %Rect{ + x: inner_x + label_w, + y: sparkline_y, + width: inner_w - label_w, + height: 1 + }}, + {%Paragraph{text: "reds/s"}, + %Rect{x: inner_x, y: sparkline_y + 2, width: label_w, height: 1}}, + {%Sparkline{data: state.reds_history, bar_set: @bar_set}, + %Rect{ + x: inner_x + label_w, + y: sparkline_y + 2, + width: inner_w - label_w, + height: 1 + }} ] - top_header = "── top by #{Atom.to_string(state.metric)} ──" - - top_lines = - sample.top - |> Enum.with_index() - |> Enum.map(fn {{pid, label, value}, idx} -> - {%Paragraph{text: format_top_line(pid, label, value, state.metric, inner_w)}, - %Rect{x: inner_x, y: row(7 + idx), width: inner_w, height: 1}} - end) - - [ - {%Block{title: " ex_ratatui · stats ", borders: [:all]}, block_rect}, - {%Paragraph{text: top_header}, %Rect{x: inner_x, y: row(6), width: inner_w, height: 1}}, - {hint_paragraph(state), %Rect{x: 2, y: frame.height - 1, width: frame.width - 4, height: 1}} - ] ++ text_widgets ++ sparkline_widgets ++ top_lines + top_header_y = sparkline_y + 4 + + top_widgets = [ + {%Paragraph{text: "── top by #{Atom.to_string(state.metric)} ──"}, + %Rect{x: inner_x, y: top_header_y, width: inner_w, height: 1}} + | sample.top + |> Enum.with_index() + |> Enum.map(fn {{pid, label, value}, idx} -> + {%Paragraph{text: format_top_line(pid, label, value, state.metric, inner_w)}, + %Rect{x: inner_x, y: top_header_y + 1 + idx, width: inner_w, height: 1}} + end) + ] + + [{block, block_rect}, {hint_paragraph(state), hint_rect}] + |> Kernel.++(summary_widgets) + |> Kernel.++(sparkline_widgets) + |> Kernel.++(top_widgets) end @impl ExRatatui.App @@ -273,20 +289,16 @@ defmodule NameBadge.Screen.ExRatatui.Stats do defp empty_sample, do: %{uptime_ms: 0, memory_kib: 0, reds_delta: 0, procs: 0, top: []} - defp row(n), do: 1 + n - defp hint_paragraph(state) do - pause_label = if state.paused?, do: " resume ", else: " pause " - - spans = [ - %Span{content: " A ", style: @reversed}, - %Span{content: pause_label}, - %Span{content: " B ", style: @reversed}, - %Span{content: " cycle "}, - %Span{content: " B long ", style: @reversed}, - %Span{content: " back"} - ] - - %Paragraph{text: spans} + pause_label = if state.paused?, do: " resume ", else: " pause " + + DemoFrame.hint([ + {" A ", :chip}, + {pause_label, :label}, + {" B ", :chip}, + {" cycle ", :label}, + {" B long ", :chip}, + {" back", :label} + ]) end end diff --git a/test/name_badge/screen/ex_ratatui/counter_test.exs b/test/name_badge/screen/ex_ratatui/counter_test.exs index 404b7b2..48025e8 100644 --- a/test/name_badge/screen/ex_ratatui/counter_test.exs +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -20,7 +20,7 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do assert {:noreply, %{count: 6}} = Counter.handle_event(key("up"), %{count: 5}) end - test "down decrements (no floor — negative counts are allowed)" do + test "down decrements" do assert {:noreply, %{count: 4}} = Counter.handle_event(key("down"), %{count: 5}) assert {:noreply, %{count: -1}} = Counter.handle_event(key("down"), %{count: 0}) end @@ -39,74 +39,59 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do describe "render/2" do setup do - [widgets: Counter.render(%{count: 7}, %Rect{x: 0, y: 0, width: 66, height: 37})] + [widgets: Counter.render(%{count: 7}, frame())] end - test "produces a bordered block, count display, and two hint lines", %{widgets: widgets} do - assert length(widgets) == 4 + test "produces the shared chrome plus a centered count and a single hint row", %{ + widgets: widgets + } do + assert length(widgets) == 3 - [{block, _}, {count, _}, {a_hints, _}, {b_hints, _}] = widgets + [{block, block_rect}, {count, count_rect}, {hint, hint_rect}] = widgets assert %Block{title: " ex_ratatui · counter ", borders: [:all]} = block assert %Paragraph{text: "count: 7", alignment: :center} = count - assert %Paragraph{text: a_spans} = a_hints - assert %Paragraph{text: b_spans} = b_hints - - assert is_list(a_spans) - assert is_list(b_spans) + assert %Paragraph{text: spans} = hint + assert is_list(spans) + + # Block fills everything but the bottom hint row. + assert block_rect.height == frame().height - 2 + # Count is roughly vertically centered in the inner content + # area (block borders take 1 cell on each side, so content + # height is frame().height - 4). + assert count_rect.y > div(frame().height, 3) + assert count_rect.y < frame().height - 4 + # Hint sits on the very bottom row. + assert hint_rect.y == frame().height - 1 end - test "keys A, A long, B, B long render in reverse-video chips", %{widgets: widgets} do - [_block, _count, {%Paragraph{text: a_spans}, _}, {%Paragraph{text: b_spans}, _}] = widgets + test "key chips render in reverse-video and action labels are plain", %{widgets: widgets} do + [_, _, {%Paragraph{text: spans}, _}] = widgets reversed = %Style{modifiers: [:reversed]} - # First and third spans on each hint line are the key chips — - # they must carry :reversed so the rasterer flips them to - # paper-on-ink. - assert %Span{content: " A ", style: ^reversed} = Enum.at(a_spans, 0) - assert %Span{content: " A long ", style: ^reversed} = Enum.at(a_spans, 2) - assert %Span{content: " B ", style: ^reversed} = Enum.at(b_spans, 0) - assert %Span{content: " B long ", style: ^reversed} = Enum.at(b_spans, 2) + # Chips at even positions (0, 2, 4, 6); labels at odd positions + # (1, 3, 5, 7). + assert %Span{content: " A ", style: ^reversed} = Enum.at(spans, 0) + assert %Span{content: " A long ", style: ^reversed} = Enum.at(spans, 2) + assert %Span{content: " B ", style: ^reversed} = Enum.at(spans, 4) + assert %Span{content: " B long ", style: ^reversed} = Enum.at(spans, 6) - # Action labels are plain (no reversal). - for span_index <- [1, 3], spans <- [a_spans, b_spans] do + for span_index <- [1, 3, 5, 7] do assert %Span{style: %Style{modifiers: []}} = Enum.at(spans, span_index) end end - test "lowercase action labels exercise the new font glyphs", %{widgets: widgets} do - [ - _block, - {%Paragraph{text: count_text}, _}, - {%Paragraph{text: a_spans}, _}, - {%Paragraph{text: b_spans}, _} - ] = widgets - - # The whole string is lowercase except the literal A / B key - # labels — proves we're not falling back to all-uppercase - # because of font gaps. - assert count_text =~ "count:" - - labels = - (a_spans ++ b_spans) - |> Enum.map(& &1.content) - |> Enum.join("") - - assert labels =~ "reset" - assert labels =~ "back" - end - test "every rect fits within the frame" do - frame = %Rect{x: 0, y: 0, width: 66, height: 37} - widgets = Counter.render(%{count: 0}, frame) + widgets = Counter.render(%{count: 0}, frame()) for {_widget, rect} <- widgets do - assert rect.x + rect.width <= frame.width - assert rect.y + rect.height <= frame.height + assert rect.x + rect.width <= frame().width + assert rect.y + rect.height <= frame().height end end end + defp frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} defp key(code), do: %Key{code: code, kind: "press", modifiers: []} end From f99d5a0e16e2aae0db405be5b9529fc0a461948f Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 14:50:50 +0200 Subject: [PATCH 21/30] feat(screen): switch Goatmire to ASCII pixel-art frames --- lib/name_badge/screen/ex_ratatui/goatmire.ex | 226 +++++++++++------- .../screen/ex_ratatui/goatmire_test.exs | 78 +++--- 2 files changed, 192 insertions(+), 112 deletions(-) diff --git a/lib/name_badge/screen/ex_ratatui/goatmire.ex b/lib/name_badge/screen/ex_ratatui/goatmire.ex index 38dc4f6..9b32365 100644 --- a/lib/name_badge/screen/ex_ratatui/goatmire.ex +++ b/lib/name_badge/screen/ex_ratatui/goatmire.ex @@ -3,27 +3,29 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do Animated Goatmire-themed greeting card — the canvas-and-subscriptions showcase for the `NameBadge.Screen.ExRatatui` adapter. - Draws a chunky 1-bit goat on a `Canvas` with the `:block` marker so - it works on the e-ink font (which has no braille glyphs), then wags - the tail on a 1 s tick declared via - `ExRatatui.Subscription.interval/3`. Built on the reducer runtime — - one `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it - doubles as a tour of how to write a self-ticking ExRatatui app. - - The 1 s tick is tuned for the badge's UC8276 partial-refresh budget - (≈ 350 ms per refresh). On the simulator this looks slower than a - desktop animation would; that's deliberate, the app should look the - same place the firmware ends up running. - - ## Layout - - ┌─ hi from ex_ratatui ─────────────────────────┐ - │ │ - │ ╓─ goat goes here ─╖ │ - │ │ - └──────────────────────────────────────────────┘ - - [ A ] pause [ A long ] reset [ B long ] back + Renders a 1-bit pixel-art goat by walking two ASCII-art frames + through `ascii_to_points/3` (each `#`/non-space character becomes a + block-marker cell on the canvas) and swapping between them on a 1 s + tick declared via `ExRatatui.Subscription.interval/3`. Built on the + reducer runtime — one `update/2` clause per `{:event, …}` / + `{:info, …}` shape — so it doubles as a tour of how to write a + self-ticking ExRatatui app. Chrome (outer block + bottom hint + strip) comes from `NameBadge.ExRatatui.DemoFrame` so every demo + uses the screen the same way. + + The 1 s tick is tuned for the badge's UC8276 partial-refresh + budget (≈ 350 ms). On the simulator this looks slower than a + desktop animation would; that's deliberate, the app should look + the same place the firmware ends up running. + + ## Editing the goat + + The pixel-art lives in two module attributes — `@frame_tail_down` + and `@frame_tail_up`. They're plain heredoc strings: `#` (or any + non-space character) is an ink pixel, ` ` is paper. To restyle + the goat, edit those strings; the frames don't need to be the + same width or height. The `@goat_origin_*` constants control + where the goat sits inside the canvas. ## Controls @@ -37,43 +39,101 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do use ExRatatui.App, runtime: :reducer alias ExRatatui.Event.Key - alias ExRatatui.Layout.Rect - alias ExRatatui.Style alias ExRatatui.Subscription - alias ExRatatui.Text.Span - alias ExRatatui.Widgets.{Block, Canvas, Paragraph} - alias ExRatatui.Widgets.Canvas.{Line, Points, Rectangle} - - @reversed %Style{modifiers: [:reversed]} - - # Tail swing parameters: ~33° amplitude around 135° (up-left). With - # the 1 s tick and a 0.5 rad/tick advance, each full wag cycle takes - # ~12.5 s — slow enough for the e-ink partial refresh to keep up, - # fast enough that the user sees the tail move every second. - @tail_step 0.5 - @tail_amplitude :math.pi() / 5.5 - @tail_baseline :math.pi() * 3 / 4 - @tail_length 6.0 + alias ExRatatui.Widgets.Canvas + alias ExRatatui.Widgets.Canvas.Points + alias NameBadge.ExRatatui.DemoFrame + @tick_interval_ms 1_000 + # Pixel-art goat in profile, head + horns on the right, body + # running horizontally across, four legs hanging down, tail next + # to the body on the left so they read as one silhouette. Two + # frames differ only in the tail position. Replace these heredocs + # with refined art any time — the renderer will pick up whatever + # shape they end up. + @frame_tail_down """ + ### + ## ## + ## ## + ## ## + ## ## + ## ## + ### + ##### + ##oo## + ######## + ### ############# + #### ################ + ### ################### + ##################### + ####################### + ####################### + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + # # # # + """ + + @frame_tail_up """ + ### + ### ## ## + #### ## ## + ### ## ## + ## ## + ## ## + ### + ##### + ##oo## + ######## + ############# + ################ + ################### + ##################### + ####################### + ####################### + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + ## ## ## ## ## + # # # # + """ + + @frames [@frame_tail_down, @frame_tail_up] + + # Goat top-left in canvas units. The render uses 1:1 cell mapping + # (1 canvas unit == 1 cell), so these are roughly cell coordinates + # within the content area. Tuned so the silhouette sits roughly + # centered with the head poking up on the right. + @goat_origin_x 0.0 + @goat_origin_y_top 30.0 + @impl ExRatatui.App def init(_opts), do: {:ok, %{tick: 0, paused?: false}} @impl ExRatatui.App def render(state, frame) do - canvas_rect = %Rect{x: 0, y: 0, width: frame.width, height: frame.height - 2} - hint_rect = %Rect{x: 2, y: frame.height - 1, width: frame.width - 4, height: 1} - + {_block, block_rect, content_rect, hint_rect} = DemoFrame.layout("goatmire", frame) + + # Canvas takes its own `:block` (the borders + title sit on the + # Canvas struct so the marker pixels paint inside them), so we + # discard the standalone DemoFrame block and paint the canvas + # across the same `block_rect`. Bounds are sized 1:1 with the + # inner content area — one canvas unit equals one cell, so pixel + # art stays square. canvas = %Canvas{ - x_bounds: {-30.0, 30.0}, - y_bounds: {-10.0, 14.0}, + x_bounds: {0.0, content_rect.width * 1.0}, + y_bounds: {0.0, content_rect.height * 1.0}, marker: :block, shapes: goat_shapes(state), - block: %Block{title: " ex_ratatui · goatmire ", borders: [:all]} + block: DemoFrame.title_block("goatmire") } [ - {canvas, canvas_rect}, + {canvas, block_rect}, {hint_paragraph(state), hint_rect} ] end @@ -98,49 +158,51 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do [Subscription.interval(:goat_tick, @tick_interval_ms, :tick)] end - # Goat geometry, in canvas units. Head on the right, tail on the - # left, only the tail moves. Coordinates are mathematical (Y grows - # up), bottom-left anchoring on rectangles per the Canvas widget. defp goat_shapes(state) do - body = %Rectangle{x: -12.0, y: -2.0, width: 14.0, height: 8.0, color: :white} - head = %Rectangle{x: 2.0, y: 2.0, width: 8.0, height: 7.0, color: :white} - - horns = [ - %Line{x1: 2.0, y1: 9.0, x2: 0.0, y2: 13.0, color: :white}, - %Line{x1: 10.0, y1: 9.0, x2: 12.0, y2: 13.0, color: :white} - ] - - eye = %Points{coords: [{8.0, 6.0}], color: :white} - beard = %Line{x1: 4.0, y1: 2.0, x2: 4.0, y2: -1.0, color: :white} - - legs = - for x <- [-10.0, -8.0, 0.0, -2.0] do - %Line{x1: x, y1: -2.0, x2: x, y2: -7.0, color: :white} - end - - [body, head, eye, beard, tail_shape(state) | horns ++ legs] + art = Enum.at(@frames, rem(state.tick, length(@frames))) + [ascii_to_points(art, @goat_origin_x, @goat_origin_y_top)] end - defp tail_shape(state) do - angle = @tail_baseline + @tail_amplitude * :math.sin(state.tick * @tail_step) - {bx, by} = {-12.0, 5.0} - tx = bx + @tail_length * :math.cos(angle) - ty = by + @tail_length * :math.sin(angle) - %Line{x1: bx, y1: by, x2: tx, y2: ty, color: :white} + @doc """ + Walks an ASCII-art string and emits a single + `%ExRatatui.Widgets.Canvas.Points{}` carrying one coordinate per + non-space character. Row 0 of the art lines up with `y_origin`; + each subsequent row sits one canvas-unit below. Designed to be + fed straight into a `:block`-marker `Canvas` whose bounds are + sized 1:1 with cells. + + Public so tests and refinement scripts can call it without + reaching into the module's private API. + """ + @spec ascii_to_points(String.t(), number(), number()) :: Points.t() + def ascii_to_points(art, x_origin, y_origin) when is_binary(art) do + coords = + art + |> String.split("\n") + |> Enum.with_index() + |> Enum.flat_map(fn {row_str, row} -> + row_str + |> String.graphemes() + |> Enum.with_index() + |> Enum.flat_map(fn + {" ", _col} -> [] + {_char, col} -> [{x_origin + col * 1.0, y_origin - row * 1.0}] + end) + end) + + %Points{coords: coords, color: :white} end defp hint_paragraph(state) do - pause_label = if state.paused?, do: " resume ", else: " pause " - - spans = [ - %Span{content: " A ", style: @reversed}, - %Span{content: pause_label}, - %Span{content: " A long ", style: @reversed}, - %Span{content: " reset "}, - %Span{content: " B long ", style: @reversed}, - %Span{content: " back"} - ] - - %Paragraph{text: spans} + pause_label = if state.paused?, do: " resume ", else: " pause " + + DemoFrame.hint([ + {" A ", :chip}, + {pause_label, :label}, + {" A long ", :chip}, + {" reset ", :label}, + {" B long ", :chip}, + {" back", :label} + ]) end end diff --git a/test/name_badge/screen/ex_ratatui/goatmire_test.exs b/test/name_badge/screen/ex_ratatui/goatmire_test.exs index 51d123e..5e6c03c 100644 --- a/test/name_badge/screen/ex_ratatui/goatmire_test.exs +++ b/test/name_badge/screen/ex_ratatui/goatmire_test.exs @@ -7,7 +7,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do alias ExRatatui.Subscription alias ExRatatui.Text.Span alias ExRatatui.Widgets.{Block, Canvas, Paragraph} - alias ExRatatui.Widgets.Canvas.{Line, Points, Rectangle} + alias ExRatatui.Widgets.Canvas.Points alias NameBadge.Screen.ExRatatui.Goatmire describe "init/1" do @@ -25,11 +25,10 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do Goatmire.update({:event, key("up")}, %{tick: 7, paused?: true}) end - test "A long (home) snaps the tail back to rest by zeroing tick" do + test "A long (home) snaps the goat back to the rest frame by zeroing tick" do assert {:noreply, %{tick: 0, paused?: false}} = Goatmire.update({:event, key("home")}, %{tick: 99, paused?: false}) - # Reset is independent of paused state. assert {:noreply, %{tick: 0, paused?: true}} = Goatmire.update({:event, key("home")}, %{tick: 99, paused?: true}) end @@ -78,9 +77,37 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do end end + describe "ascii_to_points/3" do + test "emits one coordinate per non-space character, with row 0 at y_origin" do + art = """ + ## + ### + """ + + %Points{coords: coords, color: :white} = Goatmire.ascii_to_points(art, 0.0, 10.0) + + # 5 non-space chars (`##` then `###`). + assert length(coords) == 5 + + # Row 0 (" ##") sits on y_origin = 10.0; row 1 ("###") sits at 9.0. + ys = Enum.map(coords, fn {_x, y} -> y end) |> Enum.uniq() |> Enum.sort() + assert ys == [9.0, 10.0] + + # Spaces don't emit pixels — row 1 is " ###", so the leading + # column is absent and we get coords at x = 1, 2, 3. + row_1_xs = for {x, 9.0} <- coords, do: x + assert row_1_xs == [1.0, 2.0, 3.0] + end + + test "respects the x and y origins" do + %Points{coords: [{x, y}]} = Goatmire.ascii_to_points("#", 7.5, 3.0) + assert {x, y} == {7.5, 3.0} + end + end + describe "render/2" do setup do - [widgets: Goatmire.render(%{tick: 4, paused?: false}, frame())] + [widgets: Goatmire.render(%{tick: 0, paused?: false}, frame())] end test "produces a bordered canvas plus a hint paragraph", %{widgets: widgets} do @@ -96,38 +123,30 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do assert %Paragraph{text: spans} = hint assert is_list(spans) - # Canvas takes everything but the bottom hint row. + # Canvas owns the screen above the hint row. assert canvas_rect.height == frame().height - 2 assert hint_rect.y == frame().height - 1 end - test "canvas carries the goat's static parts and the animated tail", %{widgets: widgets} do + test "the goat renders as a Points shape with many ink cells", %{widgets: widgets} do [{%Canvas{shapes: shapes}, _}, _] = widgets - assert %Rectangle{x: -12.0, width: 14.0} = - Enum.find(shapes, &match?(%Rectangle{x: -12.0}, &1)) - - assert %Rectangle{x: 2.0, width: 8.0} = Enum.find(shapes, &match?(%Rectangle{x: 2.0}, &1)) - - assert Enum.any?(shapes, &match?(%Points{coords: [{8.0, 6.0}]}, &1)) - - # 2 horns + 4 legs + 1 beard + 1 tail = 8 lines on the canvas. - lines = Enum.filter(shapes, &match?(%Line{}, &1)) - assert length(lines) == 8 + points = Enum.find(shapes, &match?(%Points{}, &1)) + assert %Points{coords: coords} = points + # The pixel-art goat is a substantial silhouette; if it ever + # drops below this, something has gone wrong with the helper or + # the heredoc trimming. + assert length(coords) > 50 end - test "tail tip moves between successive ticks (animation alive)" do + test "frames alternate between successive ticks (animation alive)" do [{%Canvas{shapes: shapes_a}, _}, _] = Goatmire.render(%{tick: 0, paused?: false}, frame()) - [{%Canvas{shapes: shapes_b}, _}, _] = Goatmire.render(%{tick: 3, paused?: false}, frame()) + [{%Canvas{shapes: shapes_b}, _}, _] = Goatmire.render(%{tick: 1, paused?: false}, frame()) - tail_a = tail_line(shapes_a) - tail_b = tail_line(shapes_b) + coords_a = shapes_a |> points_coords() |> MapSet.new() + coords_b = shapes_b |> points_coords() |> MapSet.new() - # Tail base is fixed; tip should differ across ticks because of - # the sin(tick * step) factor. - assert {tail_a.x1, tail_a.y1} == {-12.0, 5.0} - assert {tail_b.x1, tail_b.y1} == {-12.0, 5.0} - refute {tail_a.x2, tail_a.y2} == {tail_b.x2, tail_b.y2} + refute MapSet.equal?(coords_a, coords_b) end test "hint reflects pause state" do @@ -163,10 +182,9 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do defp frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} defp key(code), do: %Key{code: code, kind: "press", modifiers: []} - defp tail_line(shapes) do - Enum.find(shapes, fn - %Line{x1: -12.0, y1: 5.0} -> true - _ -> false - end) + defp points_coords(shapes) do + shapes + |> Enum.find(&match?(%Points{}, &1)) + |> Map.get(:coords) end end From 1ad7a4c877a6c4fa3e6710d1966cedf2ba712467 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 18:54:29 +0200 Subject: [PATCH 22/30] feat(ex_ratatui): polish demos --- .gitignore | 5 +- lib/name_badge/ex_ratatui/demo_frame.ex | 2 +- .../ex_ratatui/{goatmire.ex => goathi.ex} | 82 +++-- lib/name_badge/screen/ex_ratatui/stats.ex | 327 +++++++++++++----- .../screen/{goatmire.ex => goathi.ex} | 6 +- lib/name_badge/screen/top_level.ex | 2 +- .../screen/ex_ratatui/counter_test.exs | 2 +- .../{goatmire_test.exs => goathi_test.exs} | 89 +++-- .../screen/ex_ratatui/stats_test.exs | 115 +++++- 9 files changed, 482 insertions(+), 148 deletions(-) rename lib/name_badge/screen/ex_ratatui/{goatmire.ex => goathi.ex} (72%) rename lib/name_badge/screen/{goatmire.ex => goathi.ex} (63%) rename test/name_badge/screen/ex_ratatui/{goatmire_test.exs => goathi_test.exs} (60%) diff --git a/.gitignore b/.gitignore index 9e19ebd..3520cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ erl_crash.dump .mise.local.toml # ignore macOS files -.DS_Store \ No newline at end of file +.DS_Store + +# dev notes +/docs/dev/ diff --git a/lib/name_badge/ex_ratatui/demo_frame.ex b/lib/name_badge/ex_ratatui/demo_frame.ex index 12c59a1..2408de1 100644 --- a/lib/name_badge/ex_ratatui/demo_frame.ex +++ b/lib/name_badge/ex_ratatui/demo_frame.ex @@ -85,7 +85,7 @@ defmodule NameBadge.ExRatatui.DemoFrame do """ @spec title_block(String.t()) :: Block.t() def title_block(title) when is_binary(title) do - %Block{title: " ex_ratatui · #{title} ", borders: [:all]} + %Block{title: " ex_ratatui - #{title} ", borders: [:all]} end @doc """ diff --git a/lib/name_badge/screen/ex_ratatui/goatmire.ex b/lib/name_badge/screen/ex_ratatui/goathi.ex similarity index 72% rename from lib/name_badge/screen/ex_ratatui/goatmire.ex rename to lib/name_badge/screen/ex_ratatui/goathi.ex index 9b32365..43bc520 100644 --- a/lib/name_badge/screen/ex_ratatui/goatmire.ex +++ b/lib/name_badge/screen/ex_ratatui/goathi.ex @@ -1,17 +1,21 @@ -defmodule NameBadge.Screen.ExRatatui.Goatmire do +defmodule NameBadge.Screen.ExRatatui.Goathi do @moduledoc """ - Animated Goatmire-themed greeting card — the canvas-and-subscriptions - showcase for the `NameBadge.Screen.ExRatatui` adapter. + Animated Goathi greeting — the canvas-and-subscriptions showcase + for the `NameBadge.Screen.ExRatatui` adapter and the screen that + says "hi!" from ex_ratatui at the conference. Renders a 1-bit pixel-art goat by walking two ASCII-art frames through `ascii_to_points/3` (each `#`/non-space character becomes a block-marker cell on the canvas) and swapping between them on a 1 s - tick declared via `ExRatatui.Subscription.interval/3`. Built on the - reducer runtime — one `update/2` clause per `{:event, …}` / - `{:info, …}` shape — so it doubles as a tour of how to write a - self-ticking ExRatatui app. Chrome (outer block + bottom hint - strip) comes from `NameBadge.ExRatatui.DemoFrame` so every demo - uses the screen the same way. + tick declared via `ExRatatui.Subscription.interval/3`. A "HI!" + pixel-art word blinks on/off in counter-rhythm with the wagging + tail — when the tail is down the goat "speaks", when it lifts the + tail the word disappears. Built on the reducer runtime — one + `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it + doubles as a tour of how to write a self-ticking ExRatatui app. + Chrome (outer block + bottom hint strip) comes from + `NameBadge.ExRatatui.DemoFrame` so every demo uses the screen the + same way. The 1 s tick is tuned for the badge's UC8276 partial-refresh budget (≈ 350 ms). On the simulator this looks slower than a @@ -20,12 +24,13 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do ## Editing the goat - The pixel-art lives in two module attributes — `@frame_tail_down` - and `@frame_tail_up`. They're plain heredoc strings: `#` (or any - non-space character) is an ink pixel, ` ` is paper. To restyle - the goat, edit those strings; the frames don't need to be the - same width or height. The `@goat_origin_*` constants control - where the goat sits inside the canvas. + The pixel-art lives in three module attributes — `@frame_tail_down`, + `@frame_tail_up`, and `@hi_art`. They're plain heredoc strings: + any non-space character is an ink pixel, ` ` is paper. To restyle + the goat or the greeting, edit those strings; the frames don't + need to be the same width or height. The `@goat_origin_*` and + `@hi_origin_*` constants control where each shape sits inside the + canvas. ## Controls @@ -104,6 +109,18 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do @frames [@frame_tail_down, @frame_tail_up] + # 5×14 "HI!" word in the same pixel-art style as the goat. Sits in + # the top-left of the canvas where both wag frames are empty, so it + # never overlaps the silhouette. Toggled on/off in counter-rhythm + # with the tail wag. + @hi_art """ + ## ## ### ## + ## ## # ## + ###### # ## + ## ## # + ## ## ### ## + """ + # Goat top-left in canvas units. The render uses 1:1 cell mapping # (1 canvas unit == 1 cell), so these are roughly cell coordinates # within the content area. Tuned so the silhouette sits roughly @@ -111,12 +128,18 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do @goat_origin_x 0.0 @goat_origin_y_top 30.0 + # HI! word top-left in canvas units. Sits above the (empty) tail + # area at the top-left of the canvas; high enough to clear the + # tail-up frame's lifted tail. + @hi_origin_x 2.0 + @hi_origin_y_top 32.0 + @impl ExRatatui.App def init(_opts), do: {:ok, %{tick: 0, paused?: false}} @impl ExRatatui.App def render(state, frame) do - {_block, block_rect, content_rect, hint_rect} = DemoFrame.layout("goatmire", frame) + {_block, block_rect, content_rect, hint_rect} = DemoFrame.layout("goathi", frame) # Canvas takes its own `:block` (the borders + title sit on the # Canvas struct so the marker pixels paint inside them), so we @@ -128,8 +151,8 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do x_bounds: {0.0, content_rect.width * 1.0}, y_bounds: {0.0, content_rect.height * 1.0}, marker: :block, - shapes: goat_shapes(state), - block: DemoFrame.title_block("goatmire") + shapes: shapes(state), + block: DemoFrame.title_block("goathi") } [ @@ -158,11 +181,30 @@ defmodule NameBadge.Screen.ExRatatui.Goatmire do [Subscription.interval(:goat_tick, @tick_interval_ms, :tick)] end - defp goat_shapes(state) do + defp shapes(state) do art = Enum.at(@frames, rem(state.tick, length(@frames))) - [ascii_to_points(art, @goat_origin_x, @goat_origin_y_top)] + goat = ascii_to_points(art, @goat_origin_x, @goat_origin_y_top) + + if hi_visible?(state.tick) do + [ascii_to_points(@hi_art, @hi_origin_x, @hi_origin_y_top), goat] + else + [goat] + end end + @doc """ + Whether the "HI!" word is shown on the canvas at the given tick. + Public so tests can assert the alternation without going through + the full `render/2` pipeline. + + HI! shows on even ticks (when the tail is down) and disappears on + odd ticks (when the tail is up), so the two animations breathe + together at half the tick rate each. + """ + @spec hi_visible?(non_neg_integer()) :: boolean() + def hi_visible?(tick) when is_integer(tick) and tick >= 0, + do: rem(tick, 2) == 0 + @doc """ Walks an ASCII-art string and emits a single `%ExRatatui.Widgets.Canvas.Points{}` carrying one coordinate per diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index c2fa469..cab999f 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -2,31 +2,56 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @moduledoc """ Live BEAM system monitor — the data-driven showcase for `NameBadge.Screen.ExRatatui`. Refreshes every 3 seconds through an - interval subscription, keeps a rolling 50-sample history of memory - and reduction throughput (≈ 2.5 minutes), and renders both as - `Sparkline`s using a three-level bar set (`" "`, `"▄"`, `"█"`) that - the badge font ships glyphs for. + interval subscription and tells a one-glance story of what the VM + on the badge is actually doing. + + The screen is laid out from top to bottom as a small infographic: + + ┌─ ex_ratatui - stats ────────────────────────┐ + │┌─ summary ─────────────────────────────────┐│ + ││ BEAM live - uptime 3m 12s - processes 312 ││ + │└───────────────────────────────────────────┘│ + │┌─ memory by category ──────────────────────┐│ + ││ proc ████████████████ 8.2 MiB ││ + ││ bin ████████ 4.1 MiB ││ + ││ ets ██ 2.0 MiB ││ + ││ code ██ 1.9 MiB ││ + ││ atom █ 0.4 MiB ││ + │└───────────────────────────────────────────┘│ + │┌─ limits ──────────────────────────────────┐│ + ││ processes [██████ ] 312 / 262144 ││ + ││ atoms [█ ] 14k / 1M ││ + │└───────────────────────────────────────────┘│ + │┌─ trends ──────────────────────────────────┐│ + ││ memory ▁▂▃▅▇█▇▅▃▂▁ ││ + ││ work/sec ▂▅▃▇█▇▅▃▂▁▂ ││ + ││ run queue ▁▁▂▁▃▂▁▁▁▂ ││ + │└───────────────────────────────────────────┘│ + │┌─ top by reductions ───────────────────────┐│ + ││ <0.123.0> Logger.Backend 1.2M ││ + ││ … ││ + │└───────────────────────────────────────────┘│ + └─────────────────────────────────────────────┘ + + [ A ] pause [ B ] cycle [ B long ] back + + Three rolling histories (50 samples ≈ 2.5 minutes) feed the + sparklines: total memory in KiB, reductions-per-tick (BEAM's unit + of scheduled work), and the total run-queue length (pending work + across all schedulers). The five "memory by category" bars come + from `:erlang.memory/0` and partition the total into the buckets + that actually matter when sizing a Nerves device. The two gauges + read out current vs. system-configured limits for processes and + atoms — both useful "is this VM about to fall over" indicators. The 3 s cadence is tuned for the badge's UC8276 partial-refresh - budget — every tick produces a new frame for memory, reductions, - and the top-N panel, and at 3 s the panel keeps up without queueing. + budget — every tick repaints the bar chart, gauges, sparklines, + and the top-N panel, and at 3 s the panel keeps up without + queueing. Process metric is user-cyclable: reductions / memory / message queue length. The metric drives the bottom "top processes" panel. - ## Layout - - ┌─ system ────────────────────────────────────┐ - │ uptime: … procs: … │ - │ memory: … reds/s: … │ - │ memory ▁▂▃▅▇█▇▅▃▂▁ │ - │ reds/s ▂▅▃▇█▇▅▃▂▁▂ │ - │ ── top by reductions ── │ - │ <0.123.0> Logger.Backend 1.2M │ - └─────────────────────────────────────────────┘ - - [ A ] pause [ B ] cycle [ B long ] back - ## Controls | Key (TUI) | Badge button | Action | @@ -42,15 +67,26 @@ defmodule NameBadge.Screen.ExRatatui.Stats do alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect alias ExRatatui.Subscription - alias ExRatatui.Widgets.{Paragraph, Sparkline} + alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} alias NameBadge.ExRatatui.DemoFrame @history_len 50 - @top_n 10 + @top_n 12 @metrics [:reductions, :memory, :message_queue_len] @bar_set [" ", "▄", "█"] @refresh_interval_ms 3_000 + # Categories shown in the "memory by category" bar chart, in the + # order they're stacked top-to-bottom. Keys must exist in the map + # returned by `:erlang.memory/0`. + @mem_categories [ + {:processes, "proc"}, + {:binary, "bin "}, + {:ets, "ets "}, + {:code, "code"}, + {:atom, "atom"} + ] + @typedoc false @type metric :: :reductions | :memory | :message_queue_len @@ -60,6 +96,11 @@ defmodule NameBadge.Screen.ExRatatui.Stats do memory_kib: non_neg_integer(), reds_delta: non_neg_integer(), procs: non_neg_integer(), + proc_limit: pos_integer(), + atom_count: non_neg_integer(), + atom_limit: pos_integer(), + queue_len: non_neg_integer(), + mem_breakdown: %{atom() => non_neg_integer()}, top: [{pid(), String.t(), non_neg_integer()}] } @@ -70,6 +111,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do last_reductions: non_neg_integer() | nil, memory_history: [non_neg_integer()], reds_history: [non_neg_integer()], + queue_history: [non_neg_integer()], sample: sample() | nil } @@ -82,6 +124,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do last_reductions: nil, memory_history: [], reds_history: [], + queue_history: [], sample: nil } |> refresh()} @@ -92,63 +135,21 @@ defmodule NameBadge.Screen.ExRatatui.Stats do {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("stats", frame) sample = state.sample || empty_sample() - # Lay the content out on a row grid that breathes — pad blank - # rows between sections so the screen doesn't bunch at the top. - inner_x = content_rect.x + 1 - inner_w = content_rect.width - 2 - label_w = 8 + # Five inset section blocks tile content_rect top-to-bottom. Each + # has a 1-cell border on every side, so child widgets get a 2-cell + # narrower / 2-row shorter content area than the section rect. + [summary_rect, mem_rect, limits_rect, trends_rect, top_rect] = + stack_rects(content_rect, [3, 7, 4, 5, 14]) - summary = [ - {0, "uptime: #{format_uptime(sample.uptime_ms)} procs: #{sample.procs}"}, - {1, - "memory: #{format_kib(sample.memory_kib)} reds/s: #{format_count(sample.reds_delta)}"} + [ + {block, block_rect}, + {hint_paragraph(state), hint_rect} ] - - summary_widgets = - for {dy, text} <- summary do - {%Paragraph{text: text}, - %Rect{x: inner_x, y: content_rect.y + dy, width: inner_w, height: 1}} - end - - sparkline_y = content_rect.y + 3 - - sparkline_widgets = [ - {%Paragraph{text: "memory"}, %Rect{x: inner_x, y: sparkline_y, width: label_w, height: 1}}, - {%Sparkline{data: state.memory_history, bar_set: @bar_set}, - %Rect{ - x: inner_x + label_w, - y: sparkline_y, - width: inner_w - label_w, - height: 1 - }}, - {%Paragraph{text: "reds/s"}, - %Rect{x: inner_x, y: sparkline_y + 2, width: label_w, height: 1}}, - {%Sparkline{data: state.reds_history, bar_set: @bar_set}, - %Rect{ - x: inner_x + label_w, - y: sparkline_y + 2, - width: inner_w - label_w, - height: 1 - }} - ] - - top_header_y = sparkline_y + 4 - - top_widgets = [ - {%Paragraph{text: "── top by #{Atom.to_string(state.metric)} ──"}, - %Rect{x: inner_x, y: top_header_y, width: inner_w, height: 1}} - | sample.top - |> Enum.with_index() - |> Enum.map(fn {{pid, label, value}, idx} -> - {%Paragraph{text: format_top_line(pid, label, value, state.metric, inner_w)}, - %Rect{x: inner_x, y: top_header_y + 1 + idx, width: inner_w, height: 1}} - end) - ] - - [{block, block_rect}, {hint_paragraph(state), hint_rect}] - |> Kernel.++(summary_widgets) - |> Kernel.++(sparkline_widgets) - |> Kernel.++(top_widgets) + |> Kernel.++(summary_section(sample, summary_rect)) + |> Kernel.++(memory_section(sample, mem_rect)) + |> Kernel.++(limits_section(sample, limits_rect)) + |> Kernel.++(trends_section(state, trends_rect)) + |> Kernel.++(top_section(sample, state.metric, top_rect)) end @impl ExRatatui.App @@ -159,7 +160,9 @@ defmodule NameBadge.Screen.ExRatatui.Stats do do: {:noreply, %{state | metric: cycle_metric(state.metric)} |> refresh()} def update({:event, %Key{code: "home"}}, state) do - {:noreply, %{state | memory_history: [], reds_history: [], last_reductions: nil} |> refresh()} + {:noreply, + %{state | memory_history: [], reds_history: [], queue_history: [], last_reductions: nil} + |> refresh()} end def update({:info, :refresh}, %{paused?: true} = state), do: {:noreply, state} @@ -173,11 +176,11 @@ defmodule NameBadge.Screen.ExRatatui.Stats do end # Pure refresh: takes a state, samples the BEAM, returns the new state. - # Public-ish (defp) but the test reaches in to drive it deterministically. @doc false def refresh(state) do {uptime_ms, _} = :erlang.statistics(:wall_clock) - memory_kib = div(:erlang.memory(:total), 1024) + mem = Map.new(:erlang.memory()) + memory_kib = div(mem.total, 1024) {total_reds, _} = :erlang.statistics(:reductions) reds_delta = @@ -187,6 +190,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do end procs = length(Process.list()) + queue_len = :erlang.statistics(:total_run_queue_lengths) top = top_processes(state.metric) sample = %{ @@ -194,6 +198,11 @@ defmodule NameBadge.Screen.ExRatatui.Stats do memory_kib: memory_kib, reds_delta: reds_delta, procs: procs, + proc_limit: :erlang.system_info(:process_limit), + atom_count: :erlang.system_info(:atom_count), + atom_limit: :erlang.system_info(:atom_limit), + queue_len: queue_len, + mem_breakdown: Map.take(mem, [:processes, :binary, :ets, :code, :atom]), top: top } @@ -202,6 +211,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do | last_reductions: total_reds, memory_history: push_history(state.memory_history, memory_kib), reds_history: push_history(state.reds_history, reds_delta), + queue_history: push_history(state.queue_history, queue_len), sample: sample } end @@ -246,6 +256,149 @@ defmodule NameBadge.Screen.ExRatatui.Stats do end end + # ── Section builders ─────────────────────────────────────────────── + # + # Each section paints its own titled `Block` over a rect carved out + # of the outer DemoFrame's content area, then layers child widgets + # on top of the block's hollow interior. Order matters: section + # blocks come first, content widgets after, so the borders never + # paint over the content. + + defp summary_section(sample, rect) do + inner = inner_rect(rect) + + text = + "BEAM live - uptime " <> + format_uptime(sample.uptime_ms) <> " - processes " <> Integer.to_string(sample.procs) + + [ + {%Block{title: " summary ", borders: [:all]}, rect}, + {%Paragraph{text: text}, %Rect{x: inner.x, y: inner.y, width: inner.width, height: 1}} + ] + end + + defp memory_section(sample, rect) do + inner = inner_rect(rect) + breakdown = sample.mem_breakdown || %{} + max_value = breakdown |> Map.values() |> Enum.max(fn -> 1 end) |> max(1) + + label_w = 6 + value_w = 10 + bar_w = max(inner.width - label_w - value_w - 1, 1) + + rows = + @mem_categories + |> Enum.with_index() + |> Enum.map(fn {{key, label}, idx} -> + bytes = Map.get(breakdown, key, 0) + fill = round(bytes / max_value * bar_w) + bar = String.duplicate("█", fill) <> String.duplicate(" ", bar_w - fill) + value = format_kib(div(bytes, 1024)) + + text = + " " <> + String.pad_trailing(label, label_w - 1) <> + bar <> " " <> String.pad_leading(value, value_w) + + {%Paragraph{text: text}, + %Rect{x: inner.x, y: inner.y + idx, width: inner.width, height: 1}} + end) + + [{%Block{title: " memory by category ", borders: [:all]}, rect} | rows] + end + + defp limits_section(sample, rect) do + inner = inner_rect(rect) + + [ + {%Block{title: " limits ", borders: [:all]}, rect}, + gauge_row("processes", sample.procs, sample.proc_limit, inner.x, inner.width, inner.y), + gauge_row( + "atoms ", + sample.atom_count, + sample.atom_limit, + inner.x, + inner.width, + inner.y + 1 + ) + ] + end + + defp gauge_row(label, value, limit, x, w, y) do + label_w = 11 + suffix = " " <> format_count(value) <> " / " <> format_count(limit) + suffix_w = byte_size(suffix) + bar_w = max(w - label_w - suffix_w - 2, 1) + + ratio = if limit > 0, do: min(value / limit, 1.0), else: 0.0 + fill = round(ratio * bar_w) + bar = "[" <> String.duplicate("█", fill) <> String.duplicate(" ", bar_w - fill) <> "]" + + text = String.pad_trailing(label, label_w) <> bar <> suffix + {%Paragraph{text: text}, %Rect{x: x, y: y, width: w, height: 1}} + end + + defp trends_section(state, rect) do + inner = inner_rect(rect) + label_w = 10 + spark_x = inner.x + label_w + spark_w = inner.width - label_w + + sparkline_rows = [ + {"memory ", state.memory_history, 0}, + {"work/sec ", state.reds_history, 1}, + {"run queue", state.queue_history, 2} + ] + + rows = + Enum.flat_map(sparkline_rows, fn {label, data, dy} -> + [ + {%Paragraph{text: label}, + %Rect{x: inner.x, y: inner.y + dy, width: label_w, height: 1}}, + {%Sparkline{data: data, bar_set: @bar_set}, + %Rect{x: spark_x, y: inner.y + dy, width: spark_w, height: 1}} + ] + end) + + [{%Block{title: " trends ", borders: [:all]}, rect} | rows] + end + + defp top_section(sample, metric, rect) do + inner = inner_rect(rect) + + rows = + sample.top + |> Enum.take(inner.height) + |> Enum.with_index() + |> Enum.map(fn {{pid, label, value}, idx} -> + {%Paragraph{text: format_top_line(pid, label, value, metric, inner.width)}, + %Rect{x: inner.x, y: inner.y + idx, width: inner.width, height: 1}} + end) + + [{%Block{title: " top by #{Atom.to_string(metric)} ", borders: [:all]}, rect} | rows] + end + + # Carve a list of stacked rects out of the parent, top-to-bottom, + # one per height in `heights`. No gap rows — sections share borders + # (each section draws its own full perimeter so adjacent sections + # produce a doubled horizontal line, which reads as a divider). + defp stack_rects(%Rect{x: x, y: y, width: w}, heights) do + {rects, _} = + Enum.map_reduce(heights, y, fn h, cursor -> + {%Rect{x: x, y: cursor, width: w, height: h}, cursor + h} + end) + + rects + end + + # Shrink a rect by 1 cell on every side — the content area inside a + # full-bordered Block. + defp inner_rect(%Rect{x: x, y: y, width: w, height: h}) do + %Rect{x: x + 1, y: y + 1, width: w - 2, height: h - 2} + end + + # ── Formatting helpers ──────────────────────────────────────────── + defp format_top_line(pid, label, value, metric, width) do pid_str = inspect(pid) value_str = format_metric(metric, value) @@ -286,8 +439,20 @@ defmodule NameBadge.Screen.ExRatatui.Stats do defp format_count(n), do: Integer.to_string(n) - defp empty_sample, - do: %{uptime_ms: 0, memory_kib: 0, reds_delta: 0, procs: 0, top: []} + defp empty_sample do + %{ + uptime_ms: 0, + memory_kib: 0, + reds_delta: 0, + procs: 0, + proc_limit: 1, + atom_count: 0, + atom_limit: 1, + queue_len: 0, + mem_breakdown: %{processes: 0, binary: 0, ets: 0, code: 0, atom: 0}, + top: [] + } + end defp hint_paragraph(state) do pause_label = if state.paused?, do: " resume ", else: " pause " diff --git a/lib/name_badge/screen/goatmire.ex b/lib/name_badge/screen/goathi.ex similarity index 63% rename from lib/name_badge/screen/goatmire.ex rename to lib/name_badge/screen/goathi.ex index ce9bd8e..e790d5c 100644 --- a/lib/name_badge/screen/goatmire.ex +++ b/lib/name_badge/screen/goathi.ex @@ -1,9 +1,9 @@ -defmodule NameBadge.Screen.Goatmire do +defmodule NameBadge.Screen.Goathi do @moduledoc """ - Menu-facing wrapper around `NameBadge.Screen.ExRatatui.Goatmire` — + Menu-facing wrapper around `NameBadge.Screen.ExRatatui.Goathi` — hosts the animated greeting through `NameBadge.Screen.ExRatatui` with the adapter's default A/B/A-long key map. """ - use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Goatmire + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Goathi end diff --git a/lib/name_badge/screen/top_level.ex b/lib/name_badge/screen/top_level.ex index 4dd072b..c117972 100644 --- a/lib/name_badge/screen/top_level.ex +++ b/lib/name_badge/screen/top_level.ex @@ -8,7 +8,7 @@ defmodule NameBadge.Screen.TopLevel do {Screen.Gallery, "Gallery"}, {Screen.Snake, "Snake"}, {Screen.Counter, "Counter"}, - {Screen.Goatmire, "Goatmire"}, + {Screen.Goathi, "Goathi"}, {Screen.Stats, "Stats"}, {Screen.Weather, "Weather"}, {Screen.Settings, "Device Settings"} diff --git a/test/name_badge/screen/ex_ratatui/counter_test.exs b/test/name_badge/screen/ex_ratatui/counter_test.exs index 48025e8..41bdb82 100644 --- a/test/name_badge/screen/ex_ratatui/counter_test.exs +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -49,7 +49,7 @@ defmodule NameBadge.Screen.ExRatatui.CounterTest do [{block, block_rect}, {count, count_rect}, {hint, hint_rect}] = widgets - assert %Block{title: " ex_ratatui · counter ", borders: [:all]} = block + assert %Block{title: " ex_ratatui - counter ", borders: [:all]} = block assert %Paragraph{text: "count: 7", alignment: :center} = count assert %Paragraph{text: spans} = hint assert is_list(spans) diff --git a/test/name_badge/screen/ex_ratatui/goatmire_test.exs b/test/name_badge/screen/ex_ratatui/goathi_test.exs similarity index 60% rename from test/name_badge/screen/ex_ratatui/goatmire_test.exs rename to test/name_badge/screen/ex_ratatui/goathi_test.exs index 5e6c03c..a0a9528 100644 --- a/test/name_badge/screen/ex_ratatui/goatmire_test.exs +++ b/test/name_badge/screen/ex_ratatui/goathi_test.exs @@ -1,4 +1,4 @@ -defmodule NameBadge.Screen.ExRatatui.GoatmireTest do +defmodule NameBadge.Screen.ExRatatui.GoathiTest do use ExUnit.Case, async: true alias ExRatatui.Event.Key @@ -8,55 +8,55 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do alias ExRatatui.Text.Span alias ExRatatui.Widgets.{Block, Canvas, Paragraph} alias ExRatatui.Widgets.Canvas.Points - alias NameBadge.Screen.ExRatatui.Goatmire + alias NameBadge.Screen.ExRatatui.Goathi describe "init/1" do test "starts at tick 0 and unpaused" do - assert {:ok, %{tick: 0, paused?: false}} = Goatmire.init([]) + assert {:ok, %{tick: 0, paused?: false}} = Goathi.init([]) end end describe "update/2 — events" do test "A (up) toggles paused?" do assert {:noreply, %{paused?: true}} = - Goatmire.update({:event, key("up")}, %{tick: 7, paused?: false}) + Goathi.update({:event, key("up")}, %{tick: 7, paused?: false}) assert {:noreply, %{paused?: false}} = - Goatmire.update({:event, key("up")}, %{tick: 7, paused?: true}) + Goathi.update({:event, key("up")}, %{tick: 7, paused?: true}) end test "A long (home) snaps the goat back to the rest frame by zeroing tick" do assert {:noreply, %{tick: 0, paused?: false}} = - Goatmire.update({:event, key("home")}, %{tick: 99, paused?: false}) + Goathi.update({:event, key("home")}, %{tick: 99, paused?: false}) assert {:noreply, %{tick: 0, paused?: true}} = - Goatmire.update({:event, key("home")}, %{tick: 99, paused?: true}) + Goathi.update({:event, key("home")}, %{tick: 99, paused?: true}) end test "ignores unmapped keys" do state = %{tick: 5, paused?: false} - assert {:noreply, ^state} = Goatmire.update({:event, key("down")}, state) - assert {:noreply, ^state} = Goatmire.update({:event, key("q")}, state) + assert {:noreply, ^state} = Goathi.update({:event, key("down")}, state) + assert {:noreply, ^state} = Goathi.update({:event, key("q")}, state) end end describe "update/2 — ticks" do test "tick advances when not paused" do assert {:noreply, %{tick: 1}} = - Goatmire.update({:info, :tick}, %{tick: 0, paused?: false}) + Goathi.update({:info, :tick}, %{tick: 0, paused?: false}) assert {:noreply, %{tick: 43}} = - Goatmire.update({:info, :tick}, %{tick: 42, paused?: false}) + Goathi.update({:info, :tick}, %{tick: 42, paused?: false}) end test "tick is a no-op when paused" do assert {:noreply, %{tick: 42}} = - Goatmire.update({:info, :tick}, %{tick: 42, paused?: true}) + Goathi.update({:info, :tick}, %{tick: 42, paused?: true}) end test "ignores unrelated info messages" do state = %{tick: 1, paused?: false} - assert {:noreply, ^state} = Goatmire.update({:info, :unrelated}, state) + assert {:noreply, ^state} = Goathi.update({:info, :unrelated}, state) end end @@ -69,7 +69,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do interval_ms: interval, message: :tick } - ] = Goatmire.subscriptions(%{tick: 0, paused?: false}) + ] = Goathi.subscriptions(%{tick: 0, paused?: false}) # Tick must clear the badge's UC8276 partial-refresh budget # (≈ 350 ms) with margin so frames don't queue on hardware. @@ -77,6 +77,17 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do end end + describe "hi_visible?/1" do + test "blinks on for even ticks, off for odd ticks" do + assert Goathi.hi_visible?(0) + refute Goathi.hi_visible?(1) + assert Goathi.hi_visible?(2) + refute Goathi.hi_visible?(3) + assert Goathi.hi_visible?(100) + refute Goathi.hi_visible?(101) + end + end + describe "ascii_to_points/3" do test "emits one coordinate per non-space character, with row 0 at y_origin" do art = """ @@ -84,7 +95,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do ### """ - %Points{coords: coords, color: :white} = Goatmire.ascii_to_points(art, 0.0, 10.0) + %Points{coords: coords, color: :white} = Goathi.ascii_to_points(art, 0.0, 10.0) # 5 non-space chars (`##` then `###`). assert length(coords) == 5 @@ -100,14 +111,14 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do end test "respects the x and y origins" do - %Points{coords: [{x, y}]} = Goatmire.ascii_to_points("#", 7.5, 3.0) + %Points{coords: [{x, y}]} = Goathi.ascii_to_points("#", 7.5, 3.0) assert {x, y} == {7.5, 3.0} end end describe "render/2" do setup do - [widgets: Goatmire.render(%{tick: 0, paused?: false}, frame())] + [widgets: Goathi.render(%{tick: 0, paused?: false}, frame())] end test "produces a bordered canvas plus a hint paragraph", %{widgets: widgets} do @@ -117,7 +128,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do assert %Canvas{ marker: :block, - block: %Block{title: " ex_ratatui · goatmire ", borders: [:all]} + block: %Block{title: " ex_ratatui - goathi ", borders: [:all]} } = canvas assert %Paragraph{text: spans} = hint @@ -131,30 +142,46 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do test "the goat renders as a Points shape with many ink cells", %{widgets: widgets} do [{%Canvas{shapes: shapes}, _}, _] = widgets - points = Enum.find(shapes, &match?(%Points{}, &1)) - assert %Points{coords: coords} = points + assert length(shapes) >= 1 + + # The goat itself is whichever Points shape has the most ink — + # the HI! word is the smaller one when it's visible. + goat = shapes |> Enum.map(& &1.coords) |> Enum.max_by(&length/1) # The pixel-art goat is a substantial silhouette; if it ever # drops below this, something has gone wrong with the helper or # the heredoc trimming. - assert length(coords) > 50 + assert length(goat) > 50 end test "frames alternate between successive ticks (animation alive)" do - [{%Canvas{shapes: shapes_a}, _}, _] = Goatmire.render(%{tick: 0, paused?: false}, frame()) - [{%Canvas{shapes: shapes_b}, _}, _] = Goatmire.render(%{tick: 1, paused?: false}, frame()) + [{%Canvas{shapes: shapes_a}, _}, _] = Goathi.render(%{tick: 0, paused?: false}, frame()) + [{%Canvas{shapes: shapes_b}, _}, _] = Goathi.render(%{tick: 1, paused?: false}, frame()) - coords_a = shapes_a |> points_coords() |> MapSet.new() - coords_b = shapes_b |> points_coords() |> MapSet.new() + coords_a = shapes_a |> all_coords() |> MapSet.new() + coords_b = shapes_b |> all_coords() |> MapSet.new() refute MapSet.equal?(coords_a, coords_b) end + test "HI! word is present on even ticks and absent on odd ones" do + [{%Canvas{shapes: shapes_even}, _}, _] = + Goathi.render(%{tick: 0, paused?: false}, frame()) + + [{%Canvas{shapes: shapes_odd}, _}, _] = + Goathi.render(%{tick: 1, paused?: false}, frame()) + + # Two Points shapes when HI! is visible (HI! + goat), one when + # it's hidden. + assert length(shapes_even) == 2 + assert length(shapes_odd) == 1 + end + test "hint reflects pause state" do [_, {%Paragraph{text: spans_running}, _}] = - Goatmire.render(%{tick: 0, paused?: false}, frame()) + Goathi.render(%{tick: 0, paused?: false}, frame()) [_, {%Paragraph{text: spans_paused}, _}] = - Goatmire.render(%{tick: 0, paused?: true}, frame()) + Goathi.render(%{tick: 0, paused?: true}, frame()) assert spans_running |> Enum.map(& &1.content) |> Enum.join() =~ "pause" assert spans_paused |> Enum.map(& &1.content) |> Enum.join() =~ "resume" @@ -170,7 +197,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do end test "every rect fits within the frame" do - widgets = Goatmire.render(%{tick: 0, paused?: false}, frame()) + widgets = Goathi.render(%{tick: 0, paused?: false}, frame()) for {_widget, rect} <- widgets do assert rect.x + rect.width <= frame().width @@ -182,9 +209,7 @@ defmodule NameBadge.Screen.ExRatatui.GoatmireTest do defp frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} defp key(code), do: %Key{code: code, kind: "press", modifiers: []} - defp points_coords(shapes) do - shapes - |> Enum.find(&match?(%Points{}, &1)) - |> Map.get(:coords) + defp all_coords(shapes) do + Enum.flat_map(shapes, fn %Points{coords: coords} -> coords end) end end diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index 8fd9cae..b469737 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -17,6 +17,26 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert is_map(state.sample) assert is_list(state.memory_history) and length(state.memory_history) == 1 assert is_list(state.reds_history) and length(state.reds_history) == 1 + assert is_list(state.queue_history) and length(state.queue_history) == 1 + end + + test "the seeded sample carries memory breakdown and limits" do + {:ok, state} = Stats.init([]) + sample = state.sample + + # Five categories that partition `:erlang.memory/0` into the + # buckets the bar chart visualizes. + assert is_map(sample.mem_breakdown) + + assert Map.keys(sample.mem_breakdown) |> Enum.sort() == + [:atom, :binary, :code, :ets, :processes] + + # Limits should always come back as positive integers from the + # live VM so the gauges have a stable denominator. + assert sample.proc_limit > 0 + assert sample.atom_limit > 0 + assert sample.atom_count >= 0 + assert sample.queue_len >= 0 end end @@ -43,11 +63,13 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert m3 == :reductions end - test "home (A long) clears histories and re-seeds with one fresh sample", %{state: state} do + test "home (A long) clears all three histories and re-seeds a fresh sample", + %{state: state} do state = %{ state | memory_history: [10, 20, 30], reds_history: [1, 2, 3], + queue_history: [4, 5, 6], last_reductions: 999_999 } @@ -55,6 +77,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert length(after_reset.memory_history) == 1 assert length(after_reset.reds_history) == 1 + assert length(after_reset.queue_history) == 1 # last_reductions was reset to nil before refresh ran, so the # delta on the seeded sample is zero. assert hd(after_reset.reds_history) == 0 @@ -71,11 +94,12 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do [state: state] end - test "appends a new sample to both histories", %{state: state} do + test "appends a new sample to all three histories", %{state: state} do assert {:noreply, after_tick} = Stats.update({:info, :refresh}, state) assert length(after_tick.memory_history) == length(state.memory_history) + 1 assert length(after_tick.reds_history) == length(state.reds_history) + 1 + assert length(after_tick.queue_history) == length(state.queue_history) + 1 end test "is a no-op when paused", %{state: state} do @@ -92,6 +116,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert length(saturated.memory_history) == 50 assert length(saturated.reds_history) == 50 + assert length(saturated.queue_history) == 50 end test "ignores unrelated info messages", %{state: state} do @@ -112,7 +137,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do # Refresh must clear the badge's UC8276 partial-refresh budget # (≈ 350 ms) with comfortable margin since each tick repaints - # the sparklines and the top-N panel. + # the bar chart, gauges, sparklines, and the top-N panel. assert interval >= 1_000 end end @@ -123,20 +148,78 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do [state: state, widgets: Stats.render(state, frame())] end - test "produces the bordered system block, two stat lines, two sparklines, top header, hint, and top rows", + test "wraps each section in its own titled block on top of the outer chrome", %{widgets: widgets} do - # 1 block + 1 top-header + 1 hint + 2 stat rows + 4 sparkline-related (2 labels + 2 charts) + N top rows. - block = Enum.find(widgets, &match?({%Block{}, _}, &1)) - assert {%Block{title: " ex_ratatui · stats ", borders: [:all]}, _} = block + titles = block_titles(widgets) + + # Outer DemoFrame block plus one block per section. + assert " ex_ratatui - stats " in titles + assert " summary " in titles + assert " memory by category " in titles + assert " limits " in titles + assert " trends " in titles + assert Enum.any?(titles, &String.starts_with?(&1, " top by ")) + # Three sparklines: memory, work/sec, run queue. sparklines = Enum.filter(widgets, &match?({%Sparkline{}, _}, &1)) - assert length(sparklines) == 2 + assert length(sparklines) == 3 for {%Sparkline{bar_set: bar_set}, _} <- sparklines do assert bar_set == [" ", "▄", "█"] end end + test "summary content reports uptime and process count in plain English", + %{widgets: widgets} do + texts = paragraph_texts(widgets) + assert Enum.any?(texts, &(&1 =~ "BEAM live" and &1 =~ "uptime" and &1 =~ "processes")) + end + + test "memory section renders one filled bar row per category", + %{widgets: widgets} do + texts = paragraph_texts(widgets) + + for label <- ["proc", "bin", "ets", "code", "atom"] do + assert Enum.any?(texts, &(String.contains?(&1, label) and String.contains?(&1, "█"))), + "expected a filled bar row for category #{label}" + end + end + + test "top-by block title reflects the cycled metric", %{state: state} do + titles_for = fn metric -> + Stats.render(%{state | metric: metric}, frame()) |> block_titles() + end + + assert " top by reductions " in titles_for.(:reductions) + assert " top by memory " in titles_for.(:memory) + assert " top by message_queue_len " in titles_for.(:message_queue_len) + end + + test "gauge rows show value/limit suffixes for processes and atoms", + %{widgets: widgets} do + texts = paragraph_texts(widgets) + + assert Enum.any?(texts, fn t -> + String.starts_with?(t, "processes") and String.contains?(t, " / ") and + String.contains?(t, "[") + end) + + assert Enum.any?(texts, fn t -> + String.starts_with?(t, "atoms") and String.contains?(t, " / ") and + String.contains?(t, "[") + end) + end + + test "sparklines are labelled memory / work/sec / run queue", + %{widgets: widgets} do + texts = paragraph_texts(widgets) + + for label <- ["memory ", "work/sec ", "run queue"] do + assert Enum.any?(texts, &(&1 == label)), + "expected a sparkline label row for #{inspect(label)}" + end + end + test "hint shows the cycle action and pause/resume label flips", %{state: state} do [_, {%Paragraph{text: spans_running}, _}] = widgets_at_hint(Stats.render(state, frame())) @@ -173,4 +256,20 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do [{:hint_marker, nil}, {hint_w, hint_r}] end + + defp block_titles(widgets) do + widgets + |> Enum.flat_map(fn + {%Block{title: title}, _} when is_binary(title) -> [title] + _ -> [] + end) + end + + defp paragraph_texts(widgets) do + widgets + |> Enum.flat_map(fn + {%Paragraph{text: text}, _} when is_binary(text) -> [text] + _ -> [] + end) + end end From b91f04ed736191be864102b9c682bd32d6ac0490 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 19:14:05 +0200 Subject: [PATCH 23/30] feat: system monitor ssh subsystem --- config/runtime.exs | 34 + config/target.exs | 12 +- lib/name_badge/system_monitor_tui.ex | 1164 ++++++++++++++++++++++++++ 3 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 config/runtime.exs create mode 100644 lib/name_badge/system_monitor_tui.ex diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..52a9b47 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,34 @@ +import Config + +# Register the live system monitor TUI as a `nerves_ssh` subsystem so +# you can drop into a full-color dashboard from your laptop without +# disturbing the badge's e-ink screen: +# +# ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui +# +# Plain `ssh nerves@wisteria.local` still gives you the regular IEx +# prompt — the subsystem only kicks in when you pass `-s`. +# +# This config lives in `runtime.exs` on purpose: `ExRatatui.SSH.subsystem/1` +# is a normal function call, and on a fresh `MIX_TARGET=trellis mix +# compile` the compile-time config files (`config.exs`, `target.exs`) +# run before Mix has compiled deps for the target — `ExRatatui.SSH` +# isn't on the code path yet, so calling it there crashes with +# `module ExRatatui.SSH is not available`. +# +# `runtime.exs` is evaluated on device boot, after every beam file in +# the release is loaded but before the OTP application controller +# starts `:nerves_ssh`, so the config it writes is in place by the +# time the daemon reads it. Standard Elixir release pattern for any +# config that can't be a pure data literal. +# +# On host builds (`MIX_TARGET=host mix run`, tests, etc.) this file +# still runs but it's harmless: `:nerves_ssh` isn't a host dep, so no +# one ever reads the env key we're writing. +if Application.spec(:nerves_ssh) do + config :nerves_ssh, + subsystems: [ + :ssh_sftpd.subsystem_spec(cwd: ~c"/"), + ExRatatui.SSH.subsystem(NameBadge.SystemMonitorTui) + ] +end diff --git a/config/target.exs b/config/target.exs index a7a113f..0fe1b85 100644 --- a/config/target.exs +++ b/config/target.exs @@ -22,7 +22,17 @@ config :nerves, :erlinit, update_clock: true # set tzdata dir for Nerves device config :tzdata, :data_dir, "/data/tzdata" -# Configure the device for SSH IEx prompt access and firmware updates +# Configure the device for SSH IEx prompt access and firmware updates. +# +# ssh nerves@wisteria.local # IEx shell +# cat name_badge.fw | ssh -s nerves@... fwup # OTA via fwup subsystem +# ssh -t nerves@wisteria.local \ # live system monitor TUI +# -s Elixir.NameBadge.SystemMonitorTui +# +# The TUI subsystem is registered in `config/runtime.exs` because +# `ExRatatui.SSH.subsystem/1` is a function call and target deps +# aren't compiled when this file is evaluated. Authorized keys, by +# contrast, are baked in at build time so you never need a password. # # * See https://hexdocs.pm/nerves_ssh/readme.html for general SSH configuration # * See https://hexdocs.pm/ssh_subsystem_fwup/readme.html for firmware updates diff --git a/lib/name_badge/system_monitor_tui.ex b/lib/name_badge/system_monitor_tui.ex new file mode 100644 index 0000000..7f05c4d --- /dev/null +++ b/lib/name_badge/system_monitor_tui.ex @@ -0,0 +1,1164 @@ +defmodule NameBadge.SystemMonitorTui do + @moduledoc """ + Three-tab live dashboard of host + BEAM metrics, registered as a + `nerves_ssh` subsystem so any client with an authorized key can drop + into a full-color TUI without disturbing the badge's e-ink screen + (which keeps showing whatever the screen carousel is on). + + Run from a laptop: + + ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui + + The `-t` is required — OpenSSH does not allocate a PTY for `-s` + subsystem mode by default, and without it keystrokes get + line-buffered locally instead of flowing into the TUI. + + Plain `ssh nerves@wisteria.local` still drops you into the regular + IEx prompt. From there you can also start the TUI manually: + + iex> NameBadge.SystemMonitorTui.run() + + Built on the ExRatatui reducer runtime — `init/1` + `update/2` + + `subscriptions/1`. Heavy `/proc` reads run off the server process + via `Command.async/2`. + + ## Widgets on display + + * `ExRatatui.Widgets.LineGauge` — RAM and swap as thin line gauges + * `ExRatatui.Widgets.BarChart` — BEAM memory pool breakdown + * `ExRatatui.Widgets.Sparkline` — per-scheduler utilization history + * `ExRatatui.Widgets.Chart` — RAM and load-average time series + * Rich text via `ExRatatui.Text.{Line, Span}` for colored badges, + keycaps, and table cells. + + ## Controls + + * `1` / `2` / `3` — switch tabs (Overview / Processes / Graphs) + * `j` / `Down` — scroll down in the process table + * `k` / `Up` — scroll up in the process table + * `q` — quit + """ + + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.{Command, Event, Layout, Layout.Rect, Style, Subscription} + alias ExRatatui.Text.{Line, Span} + + alias ExRatatui.Widgets.{ + Bar, + BarChart, + Block, + Chart, + LineGauge, + Paragraph, + Sparkline, + Table, + Tabs + } + + alias ExRatatui.Widgets.Chart.{Axis, Dataset} + alias ExRatatui.Widgets.List, as: WList + + @refresh_ms 1_000 + @top_n 20 + @history_size 60 + + # -- Reducer callbacks -- + + @impl true + def init(_opts) do + :erlang.system_flag(:scheduler_wall_time, true) + host = collect_host_info() + metrics = collect_metrics(nil) + + state = %{ + tab: 0, + selected: 0, + host: host, + metrics: metrics, + prev_sched_sample: metrics.sched_sample, + ram_history: List.duplicate(0, @history_size), + load_history: List.duplicate({0.0, 0.0, 0.0}, @history_size), + sched_history: List.duplicate(0, @history_size) + } + + {:ok, state} + end + + @impl true + def render(state, frame) do + area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height} + + [header_area, tabs_area, body_area, footer_area] = + Layout.split(area, :vertical, [ + {:length, 3}, + {:length, 3}, + {:min, 0}, + {:length, 1} + ]) + + body_widgets = + case state.tab do + 0 -> render_overview(state, body_area) + 1 -> render_processes(state, body_area) + 2 -> render_graphs(state, body_area) + end + + [ + {header_widget(state), header_area}, + {tabs_widget(state.tab), tabs_area}, + {footer_widget(), footer_area} + | body_widgets + ] + end + + @impl true + def update({:event, %Event.Key{code: "q", kind: "press"}}, state), do: {:stop, state} + + def update({:event, %Event.Key{code: "1", kind: "press"}}, state), + do: {:noreply, %{state | tab: 0}} + + def update({:event, %Event.Key{code: "2", kind: "press"}}, state), + do: {:noreply, %{state | tab: 1}} + + def update({:event, %Event.Key{code: "3", kind: "press"}}, state), + do: {:noreply, %{state | tab: 2}} + + def update({:event, %Event.Key{code: code, kind: "press"}}, state) + when code in ["j", "Down"] do + max = length(state.metrics.top_procs) - 1 + {:noreply, %{state | selected: min(state.selected + 1, max)}} + end + + def update({:event, %Event.Key{code: code, kind: "press"}}, state) + when code in ["k", "Up"] do + {:noreply, %{state | selected: max(state.selected - 1, 0)}} + end + + def update({:info, :refresh}, state) do + cmd = + Command.async( + fn -> collect_metrics(state.prev_sched_sample) end, + fn metrics -> {:metrics_collected, metrics} end + ) + + {:noreply, state, commands: [cmd], render?: false} + end + + def update({:info, {:metrics_collected, metrics}}, state) do + load = metrics.cpu_load + + new_state = %{ + state + | metrics: metrics, + prev_sched_sample: metrics.sched_sample, + ram_history: push_history(state.ram_history, ram_percent(metrics)), + load_history: push_history(state.load_history, {load.load1, load.load5, load.load15}), + sched_history: push_history(state.sched_history, sched_avg_percent(metrics)) + } + + {:noreply, new_state} + end + + def update(_msg, state), do: {:noreply, state} + + @impl true + def subscriptions(_state) do + [Subscription.interval(:refresh, @refresh_ms, :refresh)] + end + + # -- Header / Tabs / Footer -- + + defp header_widget(state) do + load = state.metrics.cpu_load + cores = max(state.host.cpu_cores || 1, 1) + + text = + Line.new( + [ + Span.new(" "), + Span.new("BEAM Monitor", style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new(" "), + Span.new("load avg", style: %Style{fg: :white, modifiers: [:bold]}), + Span.new(" ") + ] ++ + load_badge("1m", load.load1, cores) ++ + [Span.new(" ")] ++ + load_badge("5m", load.load5, cores) ++ + [Span.new(" ")] ++ + load_badge("15m", load.load15, cores) + ) + + %Paragraph{ + text: text, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("ExRatatui", style: %Style{fg: :magenta, modifiers: [:bold]}), + Span.new(" + ", style: %Style{fg: :dark_gray}), + Span.new("Nerves", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :cyan} + } + } + end + + defp load_badge(label, value, cores) do + ratio = min(value / cores, 1.0) + {bg, fg} = load_badge_colors(ratio) + + [ + Span.new(" #{label} ", style: %Style{fg: :dark_gray}), + Span.new(" #{format_load(value)} ", style: %Style{bg: bg, fg: fg, modifiers: [:bold]}) + ] + end + + defp load_badge_colors(ratio) do + cond do + ratio > 0.85 -> {:red, :white} + ratio > 0.65 -> {:yellow, :black} + true -> {:green, :black} + end + end + + defp tabs_widget(selected) do + %Tabs{ + titles: [ + tab_title("1", "Overview"), + tab_title("2", "Processes"), + tab_title("3", "Graphs") + ], + selected: selected, + style: %Style{fg: :dark_gray}, + highlight_style: %Style{fg: :yellow, modifiers: [:bold]}, + block: %Block{ + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :yellow} + } + } + end + + # The keycap pins its own `fg: :white` so the Tabs' outer `style` + # (`fg: :dark_gray`, applied to inactive tabs) can't bleed through and + # paint the digit dark_gray-on-dark_gray. On the active tab the Tabs' + # `highlight_style` patches fg to `:yellow`, which reads cleanly + # against the same dark_gray pill background. + defp tab_title(key, label) do + Line.new([ + Span.new(" #{key} ", style: %Style{bg: :dark_gray, fg: :white, modifiers: [:bold]}), + Span.new(" #{label}") + ]) + end + + defp footer_widget do + %Paragraph{ + text: + Line.new([ + Span.new(" 1 ", style: %Style{bg: :cyan, fg: :black, modifiers: [:bold]}), + Span.new("/"), + Span.new(" 2 ", style: %Style{bg: :cyan, fg: :black, modifiers: [:bold]}), + Span.new("/"), + Span.new(" 3 ", style: %Style{bg: :cyan, fg: :black, modifiers: [:bold]}), + Span.new(" tabs "), + Span.new(" j ", style: %Style{bg: :cyan, fg: :black, modifiers: [:bold]}), + Span.new("/"), + Span.new(" k ", style: %Style{bg: :cyan, fg: :black, modifiers: [:bold]}), + Span.new(" scroll "), + Span.new(" q ", style: %Style{bg: :red, fg: :white, modifiers: [:bold]}), + Span.new(" quit") + ]) + } + end + + # -- Overview Tab -- + + defp render_overview(state, area) do + [top_area, middle_area, bottom_area] = + Layout.split(area, :vertical, [ + {:percentage, 38}, + {:percentage, 27}, + {:percentage, 35} + ]) + + [host_area, beam_area] = + Layout.split(top_area, :horizontal, [{:percentage, 50}, {:percentage, 50}]) + + [mem_gauges_area, mem_pools_area] = + Layout.split(middle_area, :horizontal, [{:percentage, 45}, {:percentage, 55}]) + + memory_children = render_memory_gauges(state, mem_gauges_area) + + [ + {host_info_widget(state), host_area}, + {beam_info_widget(state), beam_area}, + {memory_pools_widget(state), mem_pools_area}, + {scheduler_widget(state), bottom_area} + | memory_children + ] + end + + defp host_info_widget(state) do + host = state.host + m = state.metrics + + {net_name, net_ip} = + case host.primary_ip do + {name, ip} -> {name, ip} + nil -> {"--", "N/A"} + end + + items = [ + info_line("OS", host.os, :white), + info_line("Kernel", host.kernel, :white), + info_line("CPU", "#{host.cpu_model} (#{host.cpu_cores})", :white), + info_line("Uptime", format_uptime_seconds(m.host_uptime), :yellow), + info_line("IP", "#{net_ip} (#{net_name})", :cyan) + ] + + %WList{ + items: items, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("host: ", style: %Style{fg: :dark_gray}), + Span.new(host.hostname, style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :cyan} + } + } + end + + defp beam_info_widget(state) do + sys = state.metrics.sys + + items = [ + info_line("OTP", sys.otp_release, :white), + info_line("ERTS", sys.erts_version, :white), + info_line("Elixir", sys.elixir_version, :white), + ratio_line("Schedulers", sys.schedulers_online, sys.schedulers), + ratio_line("Processes", sys.process_count, sys.process_limit), + ratio_line("Ports", sys.port_count, sys.port_limit), + ratio_line("Atoms", sys.atom_count, sys.atom_limit), + info_line("Uptime", format_uptime(sys.uptime_ms), :yellow) + ] + + %WList{ + items: items, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("BEAM", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + # Two LineGauge widgets stacked: RAM used / total, and BEAM share of RAM. + defp render_memory_gauges(state, area) do + [ram_area, beam_area] = + Layout.split(area, :vertical, [{:percentage, 50}, {:percentage, 50}]) + + mem = state.metrics.mem + used = mem.total - mem.available + ram_ratio = safe_ratio(used, mem.total) + {ram_fg, _} = ratio_colors(ram_ratio) + + ram_gauge = %LineGauge{ + ratio: ram_ratio, + label: "#{format_bytes(used)} / #{format_bytes(mem.total)} #{percentage_str(ram_ratio)}", + filled_style: %Style{fg: ram_fg, modifiers: [:bold]}, + unfilled_style: %Style{fg: :dark_gray}, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("RAM", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + + beam_total = mem.beam_total + beam_ratio = safe_ratio(beam_total, mem.total) + {beam_fg, _} = ratio_colors(beam_ratio) + + beam_gauge = %LineGauge{ + ratio: beam_ratio, + label: "#{format_bytes(beam_total)} #{percentage_str(beam_ratio)} of RAM", + filled_style: %Style{fg: beam_fg, modifiers: [:bold]}, + unfilled_style: %Style{fg: :dark_gray}, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("BEAM heap", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + + [{ram_gauge, ram_area}, {beam_gauge, beam_area}] + end + + # BarChart expects non_neg_integer values. BEAM memory pools are bytes, + # so we pass the raw byte counts as values and use `text_value` to show + # a human-readable label instead of the huge number. + defp memory_pools_widget(state) do + mem = state.metrics.mem + + bars = [ + pool_bar("proc", mem.processes, :cyan), + pool_bar("bin", mem.binary, :magenta), + pool_bar("ets", mem.ets, :yellow), + pool_bar("code", mem.code, :green) + ] + + %BarChart{ + data: bars, + bar_width: 6, + bar_gap: 2, + label_style: %Style{fg: :white}, + value_style: %Style{fg: :white, modifiers: [:bold]}, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("BEAM pools", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" — now ", style: %Style{fg: :dark_gray}), + Span.new("proc ", style: %Style{fg: :dark_gray}), + Span.new(format_bytes(mem.processes), style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new(" · bin ", style: %Style{fg: :dark_gray}), + Span.new(format_bytes(mem.binary), style: %Style{fg: :magenta, modifiers: [:bold]}), + Span.new(" · ets ", style: %Style{fg: :dark_gray}), + Span.new(format_bytes(mem.ets), style: %Style{fg: :yellow, modifiers: [:bold]}), + Span.new(" · code ", style: %Style{fg: :dark_gray}), + Span.new(format_bytes(mem.code), style: %Style{fg: :green, modifiers: [:bold]}), + Span.new(" ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + defp pool_bar(label, bytes, color) do + %Bar{ + label: label, + value: bytes, + text_value: format_bytes(bytes), + style: %Style{fg: color} + } + end + + # Vertical BarChart — one bar per scheduler. Bar color reflects the + # current utilization band, `text_value` renders a human percentage + # instead of the raw 0..100 integer. + defp scheduler_widget(state) do + usages = state.metrics.sys.scheduler_usage + + bars = + usages + |> Enum.with_index(1) + |> Enum.map(fn {usage, idx} -> + pct = round(max(min(usage, 1.0), 0.0) * 100) + {fg, _} = ratio_colors(usage) + + %Bar{ + label: "##{idx}", + value: pct, + text_value: "#{pct}%", + style: %Style{fg: fg} + } + end) + + avg_pct = sched_avg_percent(state.metrics) + peak_pct = sched_peak_percent(usages) + {avg_fg, _} = ratio_colors(avg_pct / 100) + {peak_fg, _} = ratio_colors(peak_pct / 100) + + %BarChart{ + data: bars, + bar_width: 3, + bar_gap: 1, + max: 100, + label_style: %Style{fg: :white}, + value_style: %Style{fg: :white, modifiers: [:bold]}, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("Scheduler utilization", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" — now avg ", style: %Style{fg: :dark_gray}), + Span.new("#{avg_pct}%", style: %Style{fg: avg_fg, modifiers: [:bold]}), + Span.new(" · peak ", style: %Style{fg: :dark_gray}), + Span.new("#{peak_pct}%", style: %Style{fg: peak_fg, modifiers: [:bold]}), + Span.new(" ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + defp sched_peak_percent([]), do: 0 + + defp sched_peak_percent(usages) do + usages + |> Enum.max() + |> max(0.0) + |> min(1.0) + |> Kernel.*(100) + |> round() + end + + # -- Processes Tab -- + + defp render_processes(state, area) do + rows = + Enum.map(state.metrics.top_procs, fn proc -> + [ + Span.new(proc.name, style: %Style{fg: :white}), + memory_cell(proc.memory), + Span.new(Integer.to_string(proc.reductions), style: %Style{fg: :green}), + msgq_cell(proc.message_queue_len) + ] + end) + + header = [ + Span.new("Process", style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new("Memory", style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new("Reductions", style: %Style{fg: :cyan, modifiers: [:bold]}), + Span.new("MsgQ", style: %Style{fg: :cyan, modifiers: [:bold]}) + ] + + table = %Table{ + rows: rows, + header: header, + widths: [ + {:percentage, 45}, + {:percentage, 20}, + {:percentage, 22}, + {:percentage, 13} + ], + selected: state.selected, + highlight_style: %Style{fg: :black, bg: :cyan, modifiers: [:bold]}, + highlight_symbol: " > ", + column_spacing: 1, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("Top", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" #{@top_n} "), + Span.new("by memory", style: %Style{fg: :dark_gray}), + Span.new(" ") + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + + [{table, area}] + end + + defp memory_cell(bytes) do + fg = + cond do + bytes >= 50 * 1_048_576 -> :red + bytes >= 5 * 1_048_576 -> :yellow + true -> :green + end + + Span.new(format_bytes(bytes), style: %Style{fg: fg, modifiers: [:bold]}) + end + + defp msgq_cell(0), do: Span.new("0", style: %Style{fg: :dark_gray}) + + defp msgq_cell(n) when n < 100, + do: Span.new(Integer.to_string(n), style: %Style{fg: :yellow, modifiers: [:bold]}) + + defp msgq_cell(n), + do: Span.new(Integer.to_string(n), style: %Style{fg: :red, modifiers: [:bold]}) + + # -- Graphs Tab -- + + defp render_graphs(state, area) do + [ram_area, load_area, sched_area] = + Layout.split(area, :vertical, [ + {:percentage, 40}, + {:percentage, 40}, + {:min, 0} + ]) + + [ + {ram_chart(state), ram_area}, + {load_chart(state), load_area}, + {sched_sparkline(state), sched_area} + ] + end + + defp ram_chart(state) do + points = indexed_points(state.ram_history) + + %Chart{ + datasets: [ + %Dataset{ + name: "RAM %", + data: points, + graph_type: :line, + marker: :braille, + style: %Style{fg: :cyan} + } + ], + x_axis: %Axis{ + bounds: {0.0, (@history_size - 1) * 1.0}, + style: %Style{fg: :dark_gray}, + labels: [" -#{@history_size}s ", " now "] + }, + y_axis: %Axis{ + title: Span.new("%", style: %Style{fg: :dark_gray}), + bounds: {0.0, 100.0}, + style: %Style{fg: :dark_gray}, + labels: ["0", "50", "100"] + }, + legend_position: :top_right, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("RAM usage", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" — now ", style: %Style{fg: :dark_gray}), + Span.new("#{ram_percent(state.metrics)}%", + style: %Style{fg: :cyan, modifiers: [:bold]} + ), + Span.new(" — last #{@history_size}s ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + defp load_chart(state) do + load1 = + state.load_history |> Enum.with_index() |> Enum.map(fn {{v, _, _}, i} -> {i * 1.0, v} end) + + load5 = + state.load_history |> Enum.with_index() |> Enum.map(fn {{_, v, _}, i} -> {i * 1.0, v} end) + + load15 = + state.load_history |> Enum.with_index() |> Enum.map(fn {{_, _, v}, i} -> {i * 1.0, v} end) + + cores = max(state.host.cpu_cores || 1, 1) + y_max = max(cores * 1.5, highest_load(state.load_history) * 1.1) + cur = state.metrics.cpu_load + + %Chart{ + datasets: [ + %Dataset{ + name: "1m", + data: load1, + graph_type: :line, + marker: :braille, + style: %Style{fg: :red} + }, + %Dataset{ + name: "5m", + data: load5, + graph_type: :line, + marker: :braille, + style: %Style{fg: :yellow} + }, + %Dataset{ + name: "15m", + data: load15, + graph_type: :line, + marker: :braille, + style: %Style{fg: :green} + } + ], + x_axis: %Axis{ + bounds: {0.0, (@history_size - 1) * 1.0}, + style: %Style{fg: :dark_gray}, + labels: [" -#{@history_size}s ", " now "] + }, + y_axis: %Axis{ + bounds: {0.0, y_max}, + style: %Style{fg: :dark_gray}, + labels: ["0", format_load(y_max / 2), format_load(y_max)] + }, + legend_position: :top_right, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("Load average", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" — #{cores} core#{if cores == 1, do: "", else: "s"} — now ", + style: %Style{fg: :dark_gray} + ), + Span.new("1m ", style: %Style{fg: :dark_gray}), + Span.new(format_load(cur.load1), style: %Style{fg: :red, modifiers: [:bold]}), + Span.new(" · 5m ", style: %Style{fg: :dark_gray}), + Span.new(format_load(cur.load5), style: %Style{fg: :yellow, modifiers: [:bold]}), + Span.new(" · 15m ", style: %Style{fg: :dark_gray}), + Span.new(format_load(cur.load15), style: %Style{fg: :green, modifiers: [:bold]}), + Span.new(" ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + defp sched_sparkline(state) do + %Sparkline{ + data: state.sched_history, + max: 100, + bar_set: :nine_levels, + style: %Style{fg: :green}, + block: %Block{ + title: + Line.new([ + Span.new(" "), + Span.new("Avg scheduler utilization", style: %Style{fg: :blue, modifiers: [:bold]}), + Span.new(" — now ", style: %Style{fg: :dark_gray}), + Span.new("#{sched_avg_percent(state.metrics)}%", + style: %Style{fg: :green, modifiers: [:bold]} + ), + Span.new(" — last #{@history_size}s ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + # -- Metrics collection (runs in Command.async) -- + + @doc false + def collect_metrics(prev_sched_sample) do + {scheduler_usage, new_sample} = collect_scheduler_usage(prev_sched_sample) + beam = :erlang.memory() + + %{ + mem: build_memory_map(File.read("/proc/meminfo"), beam), + sys: collect_system_info(scheduler_usage), + host_uptime: read_host_uptime(), + cpu_load: read_cpu_load(), + top_procs: collect_top_processes(@top_n), + sched_sample: new_sample + } + end + + # -- Host info (collected once at init) -- + + defp collect_host_info do + %{ + hostname: read_hostname(), + os: read_os_name(), + kernel: read_kernel_version(), + cpu_model: read_cpu_model(), + cpu_cores: :erlang.system_info(:logical_processors), + primary_ip: read_primary_ip() + } + end + + defp read_hostname do + case File.read("/etc/hostname") do + {:ok, name} -> String.trim(name) + _ -> to_string(:net_adm.localhost()) + end + end + + defp read_os_name do + case File.read("/etc/os-release") do + {:ok, content} -> + case Regex.run(~r/PRETTY_NAME="([^"]+)"/, content) do + [_, name] -> name + _ -> "Linux" + end + + _ -> + {family, name} = :os.type() + "#{family}/#{name}" + end + end + + defp read_kernel_version do + case File.read("/proc/version") do + {:ok, content} -> + case Regex.run(~r/Linux version (\S+)/, content) do + [_, version] -> version + _ -> "Linux" + end + + _ -> + to_string(:erlang.system_info(:system_version)) |> String.trim() + end + end + + defp read_cpu_model do + case File.read("/proc/cpuinfo") do + {:ok, content} -> + cond do + match = Regex.run(~r/model name\s*:\s*(.+)/i, content) -> + Enum.at(match, 1) |> String.trim() |> shorten_cpu_name() + + match = Regex.run(~r/Hardware\s*:\s*(.+)/i, content) -> + Enum.at(match, 1) |> String.trim() + + true -> + "Unknown" + end + + _ -> + "Unknown" + end + end + + defp shorten_cpu_name(name) do + name + |> String.replace(~r/\(R\)|\(TM\)/i, "") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end + + defp read_primary_ip do + case :inet.getifaddrs() do + {:ok, addrs} -> + addrs + |> Enum.flat_map(fn {name, opts} -> + name_str = to_string(name) + + if name_str in ["lo", "lo0"] do + [] + else + opts + |> Keyword.get_values(:addr) + |> Enum.filter(fn addr -> tuple_size(addr) == 4 end) + |> Enum.map(fn {a, b, c, d} -> {name_str, "#{a}.#{b}.#{c}.#{d}"} end) + end + end) + |> List.first() + + _ -> + nil + end + end + + # -- Dynamic data collection -- + + @doc false + def build_memory_map({:ok, content}, beam) do + total_kb = parse_meminfo_kb(content, ~r/MemTotal:\s+(\d+)\s+kB/) + available_kb = parse_meminfo_kb(content, ~r/MemAvailable:\s+(\d+)\s+kB/) + + %{ + total: total_kb * 1024, + available: available_kb * 1024, + beam_total: beam[:total], + processes: beam[:processes], + binary: beam[:binary], + ets: beam[:ets], + code: beam[:code] + } + end + + def build_memory_map(_, beam) do + total = beam[:total] + + %{ + total: total * 2, + available: total, + beam_total: total, + processes: beam[:processes], + binary: beam[:binary], + ets: beam[:ets], + code: beam[:code] + } + end + + defp parse_meminfo_kb(content, regex) do + case Regex.run(regex, content) do + [_, kb_str] -> String.to_integer(kb_str) + _ -> 0 + end + end + + defp collect_system_info(scheduler_usage) do + {uptime_ms, _} = :erlang.statistics(:wall_clock) + + %{ + otp_release: to_string(:erlang.system_info(:otp_release)), + erts_version: to_string(:erlang.system_info(:version)), + elixir_version: System.version(), + schedulers: :erlang.system_info(:schedulers), + schedulers_online: :erlang.system_info(:schedulers_online), + process_count: :erlang.system_info(:process_count), + process_limit: :erlang.system_info(:process_limit), + port_count: :erlang.system_info(:port_count), + port_limit: :erlang.system_info(:port_limit), + atom_count: :erlang.system_info(:atom_count), + atom_limit: :erlang.system_info(:atom_limit), + uptime_ms: uptime_ms, + scheduler_usage: scheduler_usage + } + end + + defp read_cpu_load do + case File.read("/proc/loadavg") do + {:ok, content} -> + case String.split(content) do + [l1, l5, l15 | _] -> + %{load1: parse_float(l1), load5: parse_float(l5), load15: parse_float(l15)} + + _ -> + %{load1: 0.0, load5: 0.0, load15: 0.0} + end + + _ -> + %{load1: 0.0, load5: 0.0, load15: 0.0} + end + end + + defp read_host_uptime do + case File.read("/proc/uptime") do + {:ok, content} -> + case content |> String.split(" ") |> List.first() |> Float.parse() do + {seconds, _} -> trunc(seconds) + :error -> 0 + end + + _ -> + {uptime_ms, _} = :erlang.statistics(:wall_clock) + div(uptime_ms, 1000) + end + end + + defp collect_scheduler_usage(prev_sample) do + online = :erlang.system_info(:schedulers_online) + wall_times = :erlang.statistics(:scheduler_wall_time_all) + + current = + wall_times + |> Enum.filter(fn {id, _, _} -> id <= online end) + |> Enum.sort_by(fn {id, _, _} -> id end) + + usage = + case prev_sample do + nil -> + List.duplicate(0.0, online) + + prev -> + Enum.zip(prev, current) + |> Enum.map(fn {{_, prev_active, prev_total}, {_, cur_active, cur_total}} -> + delta_total = cur_total - prev_total + + if delta_total > 0 do + (cur_active - prev_active) / delta_total + else + 0.0 + end + end) + end + + {usage, current} + rescue + _ -> {List.duplicate(0.0, :erlang.system_info(:schedulers_online)), nil} + end + + defp collect_top_processes(n) do + Process.list() + |> Enum.map(&process_info/1) + |> Enum.reject(&is_nil/1) + |> Enum.sort_by(& &1.memory, :desc) + |> Enum.take(n) + end + + defp process_info(pid) do + case Process.info(pid, [:registered_name, :memory, :reductions, :message_queue_len]) do + nil -> + nil + + info -> + name = + case info[:registered_name] do + [] -> inspect(pid) + name -> inspect(name) + end + + %{ + name: name, + memory: info[:memory] || 0, + reductions: info[:reductions] || 0, + message_queue_len: info[:message_queue_len] || 0 + } + end + end + + # -- History helpers -- + + defp push_history(list, value) do + [_ | rest] = list + rest ++ [value] + end + + defp indexed_points(list) do + list + |> Enum.with_index() + |> Enum.map(fn {v, i} -> {i * 1.0, v * 1.0} end) + end + + defp highest_load(history) do + history + |> Enum.map(fn {a, b, c} -> max(a, max(b, c)) end) + |> Enum.max(fn -> 1.0 end) + end + + defp ram_percent(metrics) do + mem = metrics.mem + used = mem.total - mem.available + ratio = safe_ratio(used, mem.total) + round(ratio * 100) + end + + defp sched_avg_percent(metrics) do + usage = metrics.sys.scheduler_usage + + case usage do + [] -> + 0 + + list -> + avg = Enum.sum(list) / length(list) + round(max(min(avg, 1.0), 0.0) * 100) + end + end + + # -- Info/ratio line helpers for the info lists -- + + defp info_line(label, value, value_fg) do + Line.new([ + Span.new(" "), + Span.new(String.pad_trailing("#{label}:", 11), style: %Style{fg: :dark_gray}), + Span.new(value, style: %Style{fg: value_fg}) + ]) + end + + defp ratio_line(label, used, total) do + ratio = safe_ratio(used, total) + {fg, _} = ratio_colors(ratio) + + Line.new([ + Span.new(" "), + Span.new(String.pad_trailing("#{label}:", 11), style: %Style{fg: :dark_gray}), + Span.new("#{used}", style: %Style{fg: fg, modifiers: [:bold]}), + Span.new(" / #{total}", style: %Style{fg: :dark_gray}), + Span.new(" (#{percentage_str(ratio)})", style: %Style{fg: fg}) + ]) + end + + # -- Formatting helpers -- + + @doc false + def safe_ratio(_num, 0), do: 0.0 + def safe_ratio(num, denom), do: (num / denom) |> max(0.0) |> min(1.0) + + defp ratio_colors(ratio) do + cond do + ratio > 0.85 -> {:red, :white} + ratio > 0.65 -> {:yellow, :black} + true -> {:green, :black} + end + end + + @doc false + def format_bytes(bytes) when is_number(bytes) and bytes >= 1_073_741_824, + do: "#{Float.round(bytes / 1_073_741_824, 1)} GB" + + def format_bytes(bytes) when is_number(bytes) and bytes >= 1_048_576, + do: "#{Float.round(bytes / 1_048_576, 1)} MB" + + def format_bytes(bytes) when is_number(bytes) and bytes >= 1024, + do: "#{Float.round(bytes / 1024, 1)} KB" + + def format_bytes(bytes) when is_number(bytes), do: "#{bytes} B" + def format_bytes(_), do: "0 B" + + @doc false + def format_uptime(ms), do: format_uptime_seconds(div(ms, 1000)) + + @doc false + def format_uptime_seconds(total_seconds) do + days = div(total_seconds, 86_400) + hours = div(rem(total_seconds, 86_400), 3600) + minutes = div(rem(total_seconds, 3600), 60) + seconds = rem(total_seconds, 60) + + cond do + days > 0 -> "#{days}d #{hours}h #{minutes}m" + hours > 0 -> "#{hours}h #{minutes}m #{seconds}s" + true -> "#{minutes}m #{seconds}s" + end + end + + @doc false + def format_load(value) do + :erlang.float_to_binary(value * 1.0, decimals: 2) + end + + defp parse_float(str) do + case Float.parse(str) do + {val, _} -> val + :error -> 0.0 + end + end + + @doc false + def percentage_str(ratio) do + pct = (ratio * 100) |> Float.round(0) |> trunc() + "#{pct}%" + end + + # -- Entry point -- + + @doc """ + Starts the system monitor TUI (reducer runtime) and blocks until it exits. + + Accepts the same options as `start_link/1`. + """ + def run(opts \\ []) do + {:ok, pid} = start_link(opts) + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, _reason} -> :ok + end + end +end From 01dfb93d09ec9eb40b4d5dcb5262c69a3a646c6a Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 19:28:56 +0200 Subject: [PATCH 24/30] docs(screen): add README for the ex_ratatui demo collection --- lib/name_badge/screen/ex_ratatui/README.md | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 lib/name_badge/screen/ex_ratatui/README.md diff --git a/lib/name_badge/screen/ex_ratatui/README.md b/lib/name_badge/screen/ex_ratatui/README.md new file mode 100644 index 0000000..54d35c2 --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/README.md @@ -0,0 +1,141 @@ +# ExRatatui screens on the badge + +This folder is where the badge's [ex_ratatui](https://github.com/mauricio-cassola/ex_ratatui) demos live. Three of them rotate through the regular screen carousel alongside the Snake/Weather/Calendar screens, and a fourth one (the live BEAM dashboard) hangs off SSH as a subsystem so you can pull it up from your laptop without disturbing the e-ink panel. + +The point of these screens is twofold. They're the demo for the library — the badge is a hostile rendering target (1-bit e-ink, 6×8 bitmap font, 350 ms partial-refresh budget), so anything that looks good on the badge is a fair test of ratatui-via-Rust talking to Elixir. They're also useful in their own right, especially the system monitor. + +## What's in here + +| Module | Type | What it shows | +| --- | --- | --- | +| `Counter` | reducer demo | Two-button counter, simplest possible end-to-end demo. Hits the chrome helper, the input mapping, and not much else. | +| `Goathi` | canvas + subscription demo | Animated 1-bit pixel-art goat with a tail-wagging animation, plus a "HI!" pixel-art word that blinks on/off in counter-rhythm with the wag. Both the goat and the greeting come from ASCII heredocs walked through `ascii_to_points/3` into a single `Canvas`. | +| `Stats` | data + widgets demo | Five-pane dashboard of live BEAM stats — header strip, memory-by-category bar chart, processes/atoms gauges, three rolling sparklines (memory, work-rate, run-queue), and a top-12 process panel. Refreshes every 3 s. | +| `NameBadge.SystemMonitorTui` | SSH subsystem | Three-tab full-color terminal dashboard registered as a `nerves_ssh` subsystem. Not in the on-device carousel — you reach it with `ssh -t -s`. Same library, much wider rendering surface (your laptop terminal). | + +The first three are wrapped by thin menu-facing modules under `lib/name_badge/screen/` (`Counter`, `Goathi`, `Stats`) that hand the actual app off to the `NameBadge.Screen.ExRatatui` adapter. The adapter is what bridges the cell grid that ratatui produces to the e-ink panel's pixels — it traps exits, drains the initial frame, and feeds cells through `NameBadge.ExRatatui.Raster` to produce a 1-bit bitmap with the bitmap font in `NameBadge.ExRatatui.Font`. + +## Adding a new screen + +Two layers, then one line in the carousel. + +**Write the ratatui app** under `lib/name_badge/screen/ex_ratatui/<your_screen>.ex`. This is plain ex_ratatui, no badge-specific knowledge — same shape as Counter/Goathi/Stats. + +```elixir +defmodule NameBadge.Screen.ExRatatui.Hello do + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.Event.Key + alias ExRatatui.Widgets.Paragraph + alias NameBadge.ExRatatui.DemoFrame + + @impl true + def init(_opts), do: {:ok, %{count: 0}} + + @impl true + def render(state, frame) do + {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("hello", frame) + text_rect = DemoFrame.center_row(content_rect, 1) + + [ + {block, block_rect}, + {%Paragraph{text: "hello world (#{state.count})", alignment: :center}, text_rect}, + {DemoFrame.hint([{" A ", :chip}, {" tick", :label}, {" B long ", :chip}, {" back", :label}]), hint_rect} + ] + end + + @impl true + def update({:event, %Key{code: "up"}}, state), do: {:noreply, %{state | count: state.count + 1}} + def update(_, state), do: {:noreply, state} +end +``` + +**Add the menu wrapper** at `lib/name_badge/screen/hello.ex`. This is a one-liner that lets the regular screen rotation host the ratatui app: + +```elixir +defmodule NameBadge.Screen.Hello do + use NameBadge.Screen.ExRatatui, app: NameBadge.Screen.ExRatatui.Hello +end +``` + +**Register in the carousel** by adding the wrapper to `@base_screens` in `lib/name_badge/screen/top_level.ex`: + +```elixir +@base_screens [ + ... + {Screen.Hello, "Hello"}, + ... +] +``` + +That's it. Build firmware, OTA over SSH, hold A to advance the carousel until you find your screen. + +### Two things about writing ex_ratatui apps for the badge that are worth knowing up front + +The badge font is a 6×8 bitmap with limited Unicode coverage. Plain ASCII works, box-drawing characters and the gauge blocks (`█ ▄ ▁ ▂ ▃ ▅ ▇`) work. Things that look ASCII-ish but aren't — the middle dot `·`, ellipsis `…`, smart quotes, em dashes, `|>` — render as a placeholder checker glyph. When in doubt, build firmware and look at the actual rendered output, the simulator's font has more glyphs than the badge does. + +The e-ink panel is 1-bit. Color and styling get squashed at the raster layer in `NameBadge.ExRatatui.Raster`: only `:reversed` modifier or `bg: :black` produce inverted (paper-on-ink) cells; everything else paints as ink-on-paper. Setting `border_style: %Style{fg: :cyan}` or similar does nothing on the badge but might look fancy in the simulator and the SystemMonitorTui — that's fine, just don't rely on it for legibility. + +## The shared chrome — `DemoFrame` + +`NameBadge.ExRatatui.DemoFrame` (in `lib/name_badge/ex_ratatui/`) is the helper every demo uses for its outer block + bottom hint strip. The intent is that all three screens look like siblings: same title style, same hint row layout, same border. If you reach for it in your new screen, you get that for free. + +The shape is: + +```elixir +{block, block_rect, content_rect, hint_rect} = DemoFrame.layout("hello", frame) +``` + +`block` is the outer `%Block{}` titled ` ex_ratatui - hello `. `block_rect` covers everything except the bottom row. `content_rect` is the inside of the block (one cell in from each border). `hint_rect` is the bottom row, padded two cells in from each side. + +There's also `DemoFrame.center_row(content_rect, height)` for vertically centering a content row, and `DemoFrame.hint(segments)` for building reverse-video key-chip + plain-label hint paragraphs. See Counter for the minimal example, Stats for a wallpaper-it-everywhere example. + +## The SSH subsystem (`SystemMonitorTui`) + +The full-color BEAM dashboard isn't in the carousel — it lives at `lib/name_badge/system_monitor_tui.ex` and gets registered as a `nerves_ssh` subsystem in `config/runtime.exs`: + +```elixir +config :nerves_ssh, + subsystems: [ + :ssh_sftpd.subsystem_spec(cwd: ~c"/"), + ExRatatui.SSH.subsystem(NameBadge.SystemMonitorTui) + ] +``` + +From your laptop: + +```sh +ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui +``` + +`-t` is mandatory. OpenSSH does not allocate a PTY for `-s` subsystem mode by default — without it your local terminal stays in cooked mode and keystrokes get line-buffered instead of flowing to the TUI. Plain `ssh nerves@wisteria.local` (no `-s`) still gives you the regular IEx prompt, and from IEx you can also kick the same TUI off with `NameBadge.SystemMonitorTui.run()`. + +Three tabs, switched with `1` / `2` / `3`: + +- **Overview** — host info, BEAM info, RAM and BEAM-heap line gauges, pool bar chart, scheduler bar chart. +- **Processes** — top 20 by memory, scrollable with `j` / `k`. +- **Graphs** — RAM %, load-average lines (1m/5m/15m), and a scheduler-utilization sparkline. 60 seconds of history. + +`q` quits the subsystem and disconnects the SSH session. The badge's e-ink screen carousel keeps doing its thing the whole time. + +### Adding another SSH subsystem + +The pattern is the same — write an `ExRatatui.App` module anywhere under `lib/`, then add a line to the `subsystems:` list in `runtime.exs`: + +```elixir +ExRatatui.SSH.subsystem(NameBadge.YourTui) +``` + +It has to be `runtime.exs`, not `target.exs`, because `ExRatatui.SSH.subsystem/1` is a function call and the `target.exs` config is evaluated before Mix has compiled target deps. `runtime.exs` runs at boot, after every beam file has loaded, before `:nerves_ssh` starts — so the function is safe to call there. The whole block is guarded by `if Application.spec(:nerves_ssh)` so the same config is harmless on host builds. + +## How rendering reaches the panel + +Useful to know if you ever need to debug a glyph that won't render or a layout that's off: + +1. Your ex_ratatui app produces a list of `{widget, rect}` pairs in `render/2`. +2. ratatui (the Rust crate, via NIF) walks that list and paints into a cell grid. +3. `NameBadge.Screen.ExRatatui` adapter pulls the cell session, drains it, and hands cells to `NameBadge.ExRatatui.Raster`. +4. The raster looks up each cell's character in `NameBadge.ExRatatui.Font` (a packed 6×8 bitmap), applies inversion if the cell is reversed or `bg: :black`, and writes pixels into a 400×300 1-bit bitmap. +5. The bitmap goes to `NameBadge.Display`, which sends it to the UC8276 e-ink controller. + +If something looks wrong on hardware but right in the simulator, the suspect is almost always step 4 (font glyph missing, or unintended inversion). The raster has a small set of unit tests covering inversion semantics — start there. From 9bfefc24c539742d52396db478a8fc9cc67e61ab Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Sun, 10 May 2026 20:00:59 +0200 Subject: [PATCH 25/30] feat(screen): replace Goathi side-view with a front-view face that winks --- lib/name_badge/screen/ex_ratatui/goathi.ex | 217 +++++++++--------- .../screen/ex_ratatui/goathi_test.exs | 36 ++- 2 files changed, 136 insertions(+), 117 deletions(-) diff --git a/lib/name_badge/screen/ex_ratatui/goathi.ex b/lib/name_badge/screen/ex_ratatui/goathi.ex index 43bc520..cee9fe3 100644 --- a/lib/name_badge/screen/ex_ratatui/goathi.ex +++ b/lib/name_badge/screen/ex_ratatui/goathi.ex @@ -4,18 +4,19 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do for the `NameBadge.Screen.ExRatatui` adapter and the screen that says "hi!" from ex_ratatui at the conference. - Renders a 1-bit pixel-art goat by walking two ASCII-art frames - through `ascii_to_points/3` (each `#`/non-space character becomes a - block-marker cell on the canvas) and swapping between them on a 1 s - tick declared via `ExRatatui.Subscription.interval/3`. A "HI!" - pixel-art word blinks on/off in counter-rhythm with the wagging - tail — when the tail is down the goat "speaks", when it lifts the - tail the word disappears. Built on the reducer runtime — one - `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it - doubles as a tour of how to write a self-ticking ExRatatui app. - Chrome (outer block + bottom hint strip) comes from - `NameBadge.ExRatatui.DemoFrame` so every demo uses the screen the - same way. + Renders a 1-bit pixel-art front-view goat face — outlined contour, + two ears, and two eyes drawn as filled squares — and a "HI!" + pixel-art word in the top-left of the canvas. Every other tick the + greeting flashes on and the right eye blinks (drops to a single + dash row), so the goat winks while it speaks. All shapes flow + through `ascii_to_points/3` into a single `Canvas`, layered in + order so the eye sits on top of the face outline. + + Built on the reducer runtime — one `update/2` clause per + `{:event, …}` / `{:info, …}` shape — so it doubles as a tour of + how to write a self-ticking ExRatatui app. Chrome (outer block + + bottom hint strip) comes from `NameBadge.ExRatatui.DemoFrame` so + every demo uses the screen the same way. The 1 s tick is tuned for the badge's UC8276 partial-refresh budget (≈ 350 ms). On the simulator this looks slower than a @@ -24,20 +25,18 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do ## Editing the goat - The pixel-art lives in three module attributes — `@frame_tail_down`, - `@frame_tail_up`, and `@hi_art`. They're plain heredoc strings: - any non-space character is an ink pixel, ` ` is paper. To restyle - the goat or the greeting, edit those strings; the frames don't - need to be the same width or height. The `@goat_origin_*` and - `@hi_origin_*` constants control where each shape sits inside the - canvas. + The pixel-art lives in four module attributes — `@face_art`, + `@right_eye_open`, `@right_eye_closed`, and `@hi_art`. They're + plain heredoc strings: any non-space character is an ink pixel, + ` ` is paper. The `@*_origin_*` constants control where each + shape sits inside the canvas. ## Controls - | Key (TUI) | Badge button | Action | - | --------- | ---------------- | -------------------- | - | `up` | A (single press) | Pause/resume the wag | - | `home` | A (long press) | Reset the tail to rest | + | Key (TUI) | Badge button | Action | + | --------- | ---------------- | ----------------------- | + | `up` | A (single press) | Pause/resume the blink | + | `home` | A (long press) | Reset to tick 0 | | — | B (long press) | Back to menu (handled by `NameBadge.Screen`) | """ @@ -51,86 +50,85 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do @tick_interval_ms 1_000 - # Pixel-art goat in profile, head + horns on the right, body - # running horizontally across, four legs hanging down, tail next - # to the body on the left so they read as one silhouette. Two - # frames differ only in the tail position. Replace these heredocs - # with refined art any time — the renderer will pick up whatever - # shape they end up. - @frame_tail_down """ - ### - ## ## - ## ## - ## ## - ## ## - ## ## - ### - ##### - ##oo## - ######## - ### ############# - #### ################ - ### ################### - ##################### - ####################### - ####################### - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - # # # # + # Front-view goat face: short pointy ears, wide forehead, the head + # sides going straight down through the eye row, then a hard taper + # into a narrow snout at the bottom — front-view goats read as a + # downward-pointing triangle, not a rounded blob. Contour is a + # 1-cell outline; the always-open left eye is baked in here. The + # right eye sits in negative space and gets layered in by a + # separate shape so it can blink between ticks. Everything is + # symmetric around the vertical centerline; if you tweak one side, + # mirror it. + @face_art """ + ## ## + #### #### + #### #### + ###################### + ###################### + ###### ###### + ##### ##### + #### ### #### + ### ### ### + ## ## + ## ## + ## ########## ## + ## ## ## ## + ## ## ## ## + ## ## ## + ## ## + ## ## + ## ## + ## ## + #### + ## """ - @frame_tail_up """ - ### - ### ## ## - #### ## ## - ### ## ## - ## ## - ## ## - ### - ##### - ##oo## - ######## - ############# - ################ - ################### - ##################### - ####################### - ####################### - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - ## ## ## ## ## - # # # # + # Right eye when open: 2×2 filled square. Sits above + # @right_eye_y_open so the bottom row lines up with the left eye. + @right_eye_open """ + ### + ### """ - @frames [@frame_tail_down, @frame_tail_up] + # Right eye when closed: a single 1×2 dash. Anchored to the bottom + # row of where the open eye would be, so the wink reads as the lid + # coming down rather than the eye sliding around. + @right_eye_closed """ + ### + """ - # 5×14 "HI!" word in the same pixel-art style as the goat. Sits in - # the top-left of the canvas where both wag frames are empty, so it - # never overlaps the silhouette. Toggled on/off in counter-rhythm - # with the tail wag. + # 5×14 "HI!" word in the same pixel-art style as the face. Sits in + # the top-left of the canvas where the face never reaches. @hi_art """ - ## ## ### ## - ## ## # ## - ###### # ## - ## ## # - ## ## ### ## + ## ## #### ## + ## ## ## ## + ###### ## ## + ## ## ## + ## ## #### ## """ - # Goat top-left in canvas units. The render uses 1:1 cell mapping + # Face top-left in canvas units. The render uses 1:1 cell mapping # (1 canvas unit == 1 cell), so these are roughly cell coordinates - # within the content area. Tuned so the silhouette sits roughly - # centered with the head poking up on the right. - @goat_origin_x 0.0 - @goat_origin_y_top 30.0 - - # HI! word top-left in canvas units. Sits above the (empty) tail - # area at the top-left of the canvas; high enough to clear the - # tail-up frame's lifted tail. + # within the content area. Pushes the face into the right half of + # the canvas so HI! fits on the left. + @face_origin_x 30.0 + @face_origin_y 30.0 + + # Right eye anchor. `*_open` is the y of the eye's TOP row, + # `*_closed` is the y of the dash row (which equals the eye's + # BOTTOM row). The face's left eye lives at art rows 7-8, which + # canvas-coord-wise is y=23 (top) / y=22 (bottom) given face origin + # y=30. The face's eye-row symmetry axis runs through canvas + # x=45.5 (the row-7 cheek midpoint between canvas x=33-36 and + # x=55-58). The left eye centers on canvas x=41, so the right eye + # centers on x=50 — its 3 cells span canvas x=49..51. That keeps + # the cheek-to-eye gap on both sides at 3 cells. + @right_eye_x 49.0 + @right_eye_y_open 23.0 + @right_eye_y_closed 22.0 + + # HI! word top-left. Far enough left of the face that they never + # touch even at the longest art column. @hi_origin_x 2.0 @hi_origin_y_top 32.0 @@ -182,24 +180,33 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do end defp shapes(state) do - art = Enum.at(@frames, rem(state.tick, length(@frames))) - goat = ascii_to_points(art, @goat_origin_x, @goat_origin_y_top) + face = ascii_to_points(@face_art, @face_origin_x, @face_origin_y) if hi_visible?(state.tick) do - [ascii_to_points(@hi_art, @hi_origin_x, @hi_origin_y_top), goat] + # Speaking + winking: HI! shows, right eye drops to a dash. + [ + ascii_to_points(@hi_art, @hi_origin_x, @hi_origin_y_top), + ascii_to_points(@right_eye_closed, @right_eye_x, @right_eye_y_closed), + face + ] else - [goat] + # Resting: no HI!, right eye fully open. + [ + ascii_to_points(@right_eye_open, @right_eye_x, @right_eye_y_open), + face + ] end end @doc """ - Whether the "HI!" word is shown on the canvas at the given tick. - Public so tests can assert the alternation without going through - the full `render/2` pipeline. - - HI! shows on even ticks (when the tail is down) and disappears on - odd ticks (when the tail is up), so the two animations breathe - together at half the tick rate each. + Whether the "HI!" word is shown on the canvas at the given tick, + and (matching) whether the right eye is mid-blink. Public so tests + can assert the alternation without going through the full + `render/2` pipeline. + + Greeting + wink land on even ticks; the resting open-eye pose is + on odd ticks. The two animations breathe together at half the + tick rate each. """ @spec hi_visible?(non_neg_integer()) :: boolean() def hi_visible?(tick) when is_integer(tick) and tick >= 0, diff --git a/test/name_badge/screen/ex_ratatui/goathi_test.exs b/test/name_badge/screen/ex_ratatui/goathi_test.exs index a0a9528..366438e 100644 --- a/test/name_badge/screen/ex_ratatui/goathi_test.exs +++ b/test/name_badge/screen/ex_ratatui/goathi_test.exs @@ -139,18 +139,18 @@ defmodule NameBadge.Screen.ExRatatui.GoathiTest do assert hint_rect.y == frame().height - 1 end - test "the goat renders as a Points shape with many ink cells", %{widgets: widgets} do + test "the face renders as a Points shape with many ink cells", %{widgets: widgets} do [{%Canvas{shapes: shapes}, _}, _] = widgets - assert length(shapes) >= 1 + assert length(shapes) >= 2 - # The goat itself is whichever Points shape has the most ink — - # the HI! word is the smaller one when it's visible. - goat = shapes |> Enum.map(& &1.coords) |> Enum.max_by(&length/1) - # The pixel-art goat is a substantial silhouette; if it ever + # The face contour is whichever Points shape has the most ink — + # the eye and HI! shapes are tiny by comparison. + face = shapes |> Enum.map(& &1.coords) |> Enum.max_by(&length/1) + # The pixel-art face is a substantial silhouette; if it ever # drops below this, something has gone wrong with the helper or # the heredoc trimming. - assert length(goat) > 50 + assert length(face) > 50 end test "frames alternate between successive ticks (animation alive)" do @@ -163,17 +163,29 @@ defmodule NameBadge.Screen.ExRatatui.GoathiTest do refute MapSet.equal?(coords_a, coords_b) end - test "HI! word is present on even ticks and absent on odd ones" do + test "even ticks show HI! plus a winked right-eye dash; odd ticks show neither" do [{%Canvas{shapes: shapes_even}, _}, _] = Goathi.render(%{tick: 0, paused?: false}, frame()) [{%Canvas{shapes: shapes_odd}, _}, _] = Goathi.render(%{tick: 1, paused?: false}, frame()) - # Two Points shapes when HI! is visible (HI! + goat), one when - # it's hidden. - assert length(shapes_even) == 2 - assert length(shapes_odd) == 1 + # Even tick: HI! + right-eye dash + face = 3 shapes. + # Odd tick: right-eye open + face = 2 shapes. + assert length(shapes_even) == 3 + assert length(shapes_odd) == 2 + + # The right eye lives at canvas (x, y) in the box {49..51, 22..23}. + # On even ticks only the bottom row is filled (the dash, 1×3), + # on odd ticks the whole 2×3 block is filled. + right_eye_count = fn shapes -> + shapes + |> all_coords() + |> Enum.count(fn {x, y} -> x in [49.0, 50.0, 51.0] and y in [22.0, 23.0] end) + end + + assert right_eye_count.(shapes_even) == 3 + assert right_eye_count.(shapes_odd) == 6 end test "hint reflects pause state" do From bcc36b70265e42286bf63accfec4dda209a127b8 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Mon, 11 May 2026 15:07:13 +0200 Subject: [PATCH 26/30] refactor(ex_ratatui): code-review pass before opening contribution --- .gitignore | 3 - config/runtime.exs | 4 +- config/target.exs | 4 +- .../ex_ratatui/{demo_frame.ex => frame.ex} | 6 +- .../{ => ex_ratatui}/system_monitor_tui.ex | 6 +- lib/name_badge/screen/ex_ratatui/README.md | 66 ++++++++----------- lib/name_badge/screen/ex_ratatui/counter.ex | 10 +-- lib/name_badge/screen/ex_ratatui/goathi.ex | 12 ++-- lib/name_badge/screen/ex_ratatui/stats.ex | 8 +-- lib/name_badge/screen/top_level.ex | 4 +- .../screen/ex_ratatui/stats_test.exs | 2 +- 11 files changed, 57 insertions(+), 68 deletions(-) rename lib/name_badge/ex_ratatui/{demo_frame.ex => frame.ex} (96%) rename lib/name_badge/{ => ex_ratatui}/system_monitor_tui.ex (99%) diff --git a/.gitignore b/.gitignore index 3520cbe..69f886b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,3 @@ erl_crash.dump # ignore macOS files .DS_Store - -# dev notes -/docs/dev/ diff --git a/config/runtime.exs b/config/runtime.exs index 52a9b47..d567976 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -4,7 +4,7 @@ import Config # you can drop into a full-color dashboard from your laptop without # disturbing the badge's e-ink screen: # -# ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui +# ssh -t nerves@wisteria.local -s Elixir.NameBadge.ExRatatui.SystemMonitorTui # # Plain `ssh nerves@wisteria.local` still gives you the regular IEx # prompt — the subsystem only kicks in when you pass `-s`. @@ -29,6 +29,6 @@ if Application.spec(:nerves_ssh) do config :nerves_ssh, subsystems: [ :ssh_sftpd.subsystem_spec(cwd: ~c"/"), - ExRatatui.SSH.subsystem(NameBadge.SystemMonitorTui) + ExRatatui.SSH.subsystem(NameBadge.ExRatatui.SystemMonitorTui) ] end diff --git a/config/target.exs b/config/target.exs index 0fe1b85..ca639c2 100644 --- a/config/target.exs +++ b/config/target.exs @@ -25,9 +25,9 @@ config :tzdata, :data_dir, "/data/tzdata" # Configure the device for SSH IEx prompt access and firmware updates. # # ssh nerves@wisteria.local # IEx shell -# cat name_badge.fw | ssh -s nerves@... fwup # OTA via fwup subsystem +# cat name_badge.fw | ssh -s nerves@... fwup # via fwup subsystem # ssh -t nerves@wisteria.local \ # live system monitor TUI -# -s Elixir.NameBadge.SystemMonitorTui +# -s Elixir.NameBadge.ExRatatui.SystemMonitorTui # # The TUI subsystem is registered in `config/runtime.exs` because # `ExRatatui.SSH.subsystem/1` is a function call and target deps diff --git a/lib/name_badge/ex_ratatui/demo_frame.ex b/lib/name_badge/ex_ratatui/frame.ex similarity index 96% rename from lib/name_badge/ex_ratatui/demo_frame.ex rename to lib/name_badge/ex_ratatui/frame.ex index 2408de1..39ff016 100644 --- a/lib/name_badge/ex_ratatui/demo_frame.ex +++ b/lib/name_badge/ex_ratatui/frame.ex @@ -1,4 +1,4 @@ -defmodule NameBadge.ExRatatui.DemoFrame do +defmodule NameBadge.ExRatatui.Frame do @moduledoc """ Shared chrome for the badge's `ExRatatui.App` demos. @@ -11,12 +11,12 @@ defmodule NameBadge.ExRatatui.DemoFrame do ## Usage - {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("counter", frame) + {block, block_rect, content_rect, hint_rect} = Frame.layout("counter", frame) [ {block, block_rect}, {my_content_widget, content_rect}, - {DemoFrame.hint([ + {Frame.hint([ {" A ", :chip}, {" +1 ", :label}, {" A long ", :chip}, {" reset", :label} ]), hint_rect} diff --git a/lib/name_badge/system_monitor_tui.ex b/lib/name_badge/ex_ratatui/system_monitor_tui.ex similarity index 99% rename from lib/name_badge/system_monitor_tui.ex rename to lib/name_badge/ex_ratatui/system_monitor_tui.ex index 7f05c4d..466d71d 100644 --- a/lib/name_badge/system_monitor_tui.ex +++ b/lib/name_badge/ex_ratatui/system_monitor_tui.ex @@ -1,4 +1,4 @@ -defmodule NameBadge.SystemMonitorTui do +defmodule NameBadge.ExRatatui.SystemMonitorTui do @moduledoc """ Three-tab live dashboard of host + BEAM metrics, registered as a `nerves_ssh` subsystem so any client with an authorized key can drop @@ -7,7 +7,7 @@ defmodule NameBadge.SystemMonitorTui do Run from a laptop: - ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui + ssh -t nerves@wisteria.local -s Elixir.NameBadge.ExRatatui.SystemMonitorTui The `-t` is required — OpenSSH does not allocate a PTY for `-s` subsystem mode by default, and without it keystrokes get @@ -16,7 +16,7 @@ defmodule NameBadge.SystemMonitorTui do Plain `ssh nerves@wisteria.local` still drops you into the regular IEx prompt. From there you can also start the TUI manually: - iex> NameBadge.SystemMonitorTui.run() + iex> NameBadge.ExRatatui.SystemMonitorTui.run() Built on the ExRatatui reducer runtime — `init/1` + `update/2` + `subscriptions/1`. Heavy `/proc` reads run off the server process diff --git a/lib/name_badge/screen/ex_ratatui/README.md b/lib/name_badge/screen/ex_ratatui/README.md index 54d35c2..54f008d 100644 --- a/lib/name_badge/screen/ex_ratatui/README.md +++ b/lib/name_badge/screen/ex_ratatui/README.md @@ -1,8 +1,6 @@ # ExRatatui screens on the badge -This folder is where the badge's [ex_ratatui](https://github.com/mauricio-cassola/ex_ratatui) demos live. Three of them rotate through the regular screen carousel alongside the Snake/Weather/Calendar screens, and a fourth one (the live BEAM dashboard) hangs off SSH as a subsystem so you can pull it up from your laptop without disturbing the e-ink panel. - -The point of these screens is twofold. They're the demo for the library — the badge is a hostile rendering target (1-bit e-ink, 6×8 bitmap font, 350 ms partial-refresh budget), so anything that looks good on the badge is a fair test of ratatui-via-Rust talking to Elixir. They're also useful in their own right, especially the system monitor. +This folder is where the badge's [ex_ratatui](https://github.com/mauricio-cassola/ex_ratatui) demos live. Three of them rotate through the regular screen carousel alongside the Snake/Weather/Calendar screens, and a fourth one (the live BEAM dashboard) hangs off SSH as a subsystem so you can pull it up from your laptop and monitor the badge. ## What's in here @@ -11,15 +9,15 @@ The point of these screens is twofold. They're the demo for the library — the | `Counter` | reducer demo | Two-button counter, simplest possible end-to-end demo. Hits the chrome helper, the input mapping, and not much else. | | `Goathi` | canvas + subscription demo | Animated 1-bit pixel-art goat with a tail-wagging animation, plus a "HI!" pixel-art word that blinks on/off in counter-rhythm with the wag. Both the goat and the greeting come from ASCII heredocs walked through `ascii_to_points/3` into a single `Canvas`. | | `Stats` | data + widgets demo | Five-pane dashboard of live BEAM stats — header strip, memory-by-category bar chart, processes/atoms gauges, three rolling sparklines (memory, work-rate, run-queue), and a top-12 process panel. Refreshes every 3 s. | -| `NameBadge.SystemMonitorTui` | SSH subsystem | Three-tab full-color terminal dashboard registered as a `nerves_ssh` subsystem. Not in the on-device carousel — you reach it with `ssh -t -s`. Same library, much wider rendering surface (your laptop terminal). | +| `NameBadge.ExRatatui.SystemMonitorTui` | SSH subsystem | Three-tab full-color terminal dashboard registered as a `nerves_ssh` subsystem. Not in the on-device carousel — you reach it with `ssh -t -s`. Same library, much wider rendering surface. | -The first three are wrapped by thin menu-facing modules under `lib/name_badge/screen/` (`Counter`, `Goathi`, `Stats`) that hand the actual app off to the `NameBadge.Screen.ExRatatui` adapter. The adapter is what bridges the cell grid that ratatui produces to the e-ink panel's pixels — it traps exits, drains the initial frame, and feeds cells through `NameBadge.ExRatatui.Raster` to produce a 1-bit bitmap with the bitmap font in `NameBadge.ExRatatui.Font`. +The first three are wrapped by thin menu-facing modules under `lib/name_badge/screen/` (`Counter`, `Goathi`, `Stats`) that hand the actual app off to the `NameBadge.Screen.ExRatatui` adapter. The adapter is what bridges the cell grid that ex_ratatui produces to the e-ink panel's pixels — it traps exits, drains the initial frame, and feeds cells through `NameBadge.ExRatatui.Raster` to produce a 1-bit bitmap with the bitmap font in `NameBadge.ExRatatui.Font`. ## Adding a new screen Two layers, then one line in the carousel. -**Write the ratatui app** under `lib/name_badge/screen/ex_ratatui/<your_screen>.ex`. This is plain ex_ratatui, no badge-specific knowledge — same shape as Counter/Goathi/Stats. +**Write the ex_ratatui app** under `lib/name_badge/screen/ex_ratatui/<your_screen>.ex`. This is plain ex_ratatui, no badge-specific knowledge. Same shape as Counter/Goathi/Stats. ```elixir defmodule NameBadge.Screen.ExRatatui.Hello do @@ -27,20 +25,20 @@ defmodule NameBadge.Screen.ExRatatui.Hello do alias ExRatatui.Event.Key alias ExRatatui.Widgets.Paragraph - alias NameBadge.ExRatatui.DemoFrame + alias NameBadge.ExRatatui.Frame @impl true def init(_opts), do: {:ok, %{count: 0}} @impl true def render(state, frame) do - {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("hello", frame) - text_rect = DemoFrame.center_row(content_rect, 1) + {block, block_rect, content_rect, hint_rect} = Frame.layout("hello", frame) + text_rect = Frame.center_row(content_rect, 1) [ {block, block_rect}, {%Paragraph{text: "hello world (#{state.count})", alignment: :center}, text_rect}, - {DemoFrame.hint([{" A ", :chip}, {" tick", :label}, {" B long ", :chip}, {" back", :label}]), hint_rect} + {Frame.hint([{" A ", :chip}, {" tick", :label}, {" B long ", :chip}, {" back", :label}]), hint_rect} ] end @@ -50,7 +48,7 @@ defmodule NameBadge.Screen.ExRatatui.Hello do end ``` -**Add the menu wrapper** at `lib/name_badge/screen/hello.ex`. This is a one-liner that lets the regular screen rotation host the ratatui app: +**Add the menu wrapper** at `lib/name_badge/screen/hello.ex`. This is a one-liner that lets the regular screen rotation host the ex_ratatui app: ```elixir defmodule NameBadge.Screen.Hello do @@ -67,48 +65,50 @@ end ... ] ``` - -That's it. Build firmware, OTA over SSH, hold A to advance the carousel until you find your screen. +: +That's it. ### Two things about writing ex_ratatui apps for the badge that are worth knowing up front -The badge font is a 6×8 bitmap with limited Unicode coverage. Plain ASCII works, box-drawing characters and the gauge blocks (`█ ▄ ▁ ▂ ▃ ▅ ▇`) work. Things that look ASCII-ish but aren't — the middle dot `·`, ellipsis `…`, smart quotes, em dashes, `|>` — render as a placeholder checker glyph. When in doubt, build firmware and look at the actual rendered output, the simulator's font has more glyphs than the badge does. +The badge font is a 6×8 bitmap with limited Unicode coverage. Plain ASCII works, box-drawing characters and the gauge blocks (`█ ▄ ▁ ▂ ▃ ▅ ▇`) work. But other things I tried that look ASCII-ish but aren't — the middle dot `·`, ellipsis `…`, smart quotes, em dashes, `|>` — render as a placeholder checker glyph. -The e-ink panel is 1-bit. Color and styling get squashed at the raster layer in `NameBadge.ExRatatui.Raster`: only `:reversed` modifier or `bg: :black` produce inverted (paper-on-ink) cells; everything else paints as ink-on-paper. Setting `border_style: %Style{fg: :cyan}` or similar does nothing on the badge but might look fancy in the simulator and the SystemMonitorTui — that's fine, just don't rely on it for legibility. +The e-ink panel is 1-bit. Color and styling get squashed at the raster layer in `NameBadge.ExRatatui.Raster`: only `:reversed` modifier or `bg: :black` produce inverted (paper-on-ink) cells; everything else paints as ink-on-paper. Setting `border_style: %Style{fg: :cyan}` or similar does nothing on the badge. -## The shared chrome — `DemoFrame` +## The shared chrome — `Frame` -`NameBadge.ExRatatui.DemoFrame` (in `lib/name_badge/ex_ratatui/`) is the helper every demo uses for its outer block + bottom hint strip. The intent is that all three screens look like siblings: same title style, same hint row layout, same border. If you reach for it in your new screen, you get that for free. +`NameBadge.ExRatatui.Frame` (in `lib/name_badge/ex_ratatui/`) is the helper every demo uses for its outer block + bottom hint strip. The intent is that all three screens look like siblings: same title style, same hint row layout, same border. If you reach for it in your new screen, you get that for free. The shape is: ```elixir -{block, block_rect, content_rect, hint_rect} = DemoFrame.layout("hello", frame) +{block, block_rect, content_rect, hint_rect} = Frame.layout("hello", frame) ``` `block` is the outer `%Block{}` titled ` ex_ratatui - hello `. `block_rect` covers everything except the bottom row. `content_rect` is the inside of the block (one cell in from each border). `hint_rect` is the bottom row, padded two cells in from each side. -There's also `DemoFrame.center_row(content_rect, height)` for vertically centering a content row, and `DemoFrame.hint(segments)` for building reverse-video key-chip + plain-label hint paragraphs. See Counter for the minimal example, Stats for a wallpaper-it-everywhere example. +There's also `Frame.center_row(content_rect, height)` for vertically centering a content row, and `Frame.hint(segments)` for building reverse-video key-chip + plain-label hint paragraphs. See Counter for the minimal example, Stats for a wallpaper-it-everywhere example. + +This also acts as a demo of how one could "componentize" the TUIs. More on that: https://hexdocs.pm/ex_ratatui/custom_widgets.html ## The SSH subsystem (`SystemMonitorTui`) -The full-color BEAM dashboard isn't in the carousel — it lives at `lib/name_badge/system_monitor_tui.ex` and gets registered as a `nerves_ssh` subsystem in `config/runtime.exs`: +The full-color BEAM dashboard isn't in the carousel — it lives at `lib/name_badge/ex_ratatui/system_monitor_tui.ex` and gets registered as a `nerves_ssh` subsystem in `config/runtime.exs`: ```elixir config :nerves_ssh, subsystems: [ :ssh_sftpd.subsystem_spec(cwd: ~c"/"), - ExRatatui.SSH.subsystem(NameBadge.SystemMonitorTui) + ExRatatui.SSH.subsystem(NameBadge.ExRatatui.SystemMonitorTui) ] ``` -From your laptop: +To connect: ```sh -ssh -t nerves@wisteria.local -s Elixir.NameBadge.SystemMonitorTui +ssh -t nerves@wisteria.local -s Elixir.NameBadge.ExRatatui.SystemMonitorTui ``` -`-t` is mandatory. OpenSSH does not allocate a PTY for `-s` subsystem mode by default — without it your local terminal stays in cooked mode and keystrokes get line-buffered instead of flowing to the TUI. Plain `ssh nerves@wisteria.local` (no `-s`) still gives you the regular IEx prompt, and from IEx you can also kick the same TUI off with `NameBadge.SystemMonitorTui.run()`. +`-t` is mandatory. OpenSSH does not allocate a PTY for `-s` subsystem mode by default — without it your local terminal stays in cooked mode and keystrokes get line-buffered instead of flowing to the TUI. Plain `ssh nerves@wisteria.local` (no `-s`) still gives you the regular IEx prompt, and from IEx you could also kick the same TUI off with `NameBadge.ExRatatui.SystemMonitorTui.run()` if the device has somewhere to render. Three tabs, switched with `1` / `2` / `3`: @@ -123,19 +123,11 @@ Three tabs, switched with `1` / `2` / `3`: The pattern is the same — write an `ExRatatui.App` module anywhere under `lib/`, then add a line to the `subsystems:` list in `runtime.exs`: ```elixir -ExRatatui.SSH.subsystem(NameBadge.YourTui) +ExRatatui.SSH.subsystem(NameBadge.ExRatatui.YourTui) ``` -It has to be `runtime.exs`, not `target.exs`, because `ExRatatui.SSH.subsystem/1` is a function call and the `target.exs` config is evaluated before Mix has compiled target deps. `runtime.exs` runs at boot, after every beam file has loaded, before `:nerves_ssh` starts — so the function is safe to call there. The whole block is guarded by `if Application.spec(:nerves_ssh)` so the same config is harmless on host builds. - -## How rendering reaches the panel +And then: -Useful to know if you ever need to debug a glyph that won't render or a layout that's off: - -1. Your ex_ratatui app produces a list of `{widget, rect}` pairs in `render/2`. -2. ratatui (the Rust crate, via NIF) walks that list and paints into a cell grid. -3. `NameBadge.Screen.ExRatatui` adapter pulls the cell session, drains it, and hands cells to `NameBadge.ExRatatui.Raster`. -4. The raster looks up each cell's character in `NameBadge.ExRatatui.Font` (a packed 6×8 bitmap), applies inversion if the cell is reversed or `bg: :black`, and writes pixels into a 400×300 1-bit bitmap. -5. The bitmap goes to `NameBadge.Display`, which sends it to the UC8276 e-ink controller. - -If something looks wrong on hardware but right in the simulator, the suspect is almost always step 4 (font glyph missing, or unintended inversion). The raster has a small set of unit tests covering inversion semantics — start there. +```sh +ssh -t nerves@wisteria.local -s Elixir.NameBadge.ExRatatui.YourTui +``` diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index c44a0a7..2571142 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -2,7 +2,7 @@ defmodule NameBadge.Screen.ExRatatui.Counter do @moduledoc """ A two-button TUI counter — the simplest end-to-end demo of the `NameBadge.Screen.ExRatatui` adapter, sharing chrome with every - other ExRatatui demo through `NameBadge.ExRatatui.DemoFrame`. + other ExRatatui demo through `NameBadge.ExRatatui.Frame`. ## Layout @@ -30,20 +30,20 @@ defmodule NameBadge.Screen.ExRatatui.Counter do alias ExRatatui.Event.Key alias ExRatatui.Widgets.Paragraph - alias NameBadge.ExRatatui.DemoFrame + alias NameBadge.ExRatatui.Frame @impl ExRatatui.App def mount(_opts), do: {:ok, %{count: 0}} @impl ExRatatui.App def render(state, frame) do - {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("counter", frame) - count_rect = DemoFrame.center_row(content_rect, 1) + {block, block_rect, content_rect, hint_rect} = Frame.layout("counter", frame) + count_rect = Frame.center_row(content_rect, 1) [ {block, block_rect}, {%Paragraph{text: "count: #{state.count}", alignment: :center}, count_rect}, - {DemoFrame.hint([ + {Frame.hint([ {" A ", :chip}, {" +1 ", :label}, {" A long ", :chip}, diff --git a/lib/name_badge/screen/ex_ratatui/goathi.ex b/lib/name_badge/screen/ex_ratatui/goathi.ex index cee9fe3..1bff7f4 100644 --- a/lib/name_badge/screen/ex_ratatui/goathi.ex +++ b/lib/name_badge/screen/ex_ratatui/goathi.ex @@ -15,7 +15,7 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do Built on the reducer runtime — one `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it doubles as a tour of how to write a self-ticking ExRatatui app. Chrome (outer block + - bottom hint strip) comes from `NameBadge.ExRatatui.DemoFrame` so + bottom hint strip) comes from `NameBadge.ExRatatui.Frame` so every demo uses the screen the same way. The 1 s tick is tuned for the badge's UC8276 partial-refresh @@ -46,7 +46,7 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do alias ExRatatui.Subscription alias ExRatatui.Widgets.Canvas alias ExRatatui.Widgets.Canvas.Points - alias NameBadge.ExRatatui.DemoFrame + alias NameBadge.ExRatatui.Frame @tick_interval_ms 1_000 @@ -137,11 +137,11 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do @impl ExRatatui.App def render(state, frame) do - {_block, block_rect, content_rect, hint_rect} = DemoFrame.layout("goathi", frame) + {_block, block_rect, content_rect, hint_rect} = Frame.layout("goathi", frame) # Canvas takes its own `:block` (the borders + title sit on the # Canvas struct so the marker pixels paint inside them), so we - # discard the standalone DemoFrame block and paint the canvas + # discard the standalone Frame block and paint the canvas # across the same `block_rect`. Bounds are sized 1:1 with the # inner content area — one canvas unit equals one cell, so pixel # art stays square. @@ -150,7 +150,7 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do y_bounds: {0.0, content_rect.height * 1.0}, marker: :block, shapes: shapes(state), - block: DemoFrame.title_block("goathi") + block: Frame.title_block("goathi") } [ @@ -245,7 +245,7 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do defp hint_paragraph(state) do pause_label = if state.paused?, do: " resume ", else: " pause " - DemoFrame.hint([ + Frame.hint([ {" A ", :chip}, {pause_label, :label}, {" A long ", :chip}, diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index cab999f..6abc555 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -68,7 +68,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do alias ExRatatui.Layout.Rect alias ExRatatui.Subscription alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} - alias NameBadge.ExRatatui.DemoFrame + alias NameBadge.ExRatatui.Frame @history_len 50 @top_n 12 @@ -132,7 +132,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @impl ExRatatui.App def render(state, frame) do - {block, block_rect, content_rect, hint_rect} = DemoFrame.layout("stats", frame) + {block, block_rect, content_rect, hint_rect} = Frame.layout("stats", frame) sample = state.sample || empty_sample() # Five inset section blocks tile content_rect top-to-bottom. Each @@ -259,7 +259,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do # ── Section builders ─────────────────────────────────────────────── # # Each section paints its own titled `Block` over a rect carved out - # of the outer DemoFrame's content area, then layers child widgets + # of the outer Frame's content area, then layers child widgets # on top of the block's hollow interior. Order matters: section # blocks come first, content widgets after, so the borders never # paint over the content. @@ -457,7 +457,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do defp hint_paragraph(state) do pause_label = if state.paused?, do: " resume ", else: " pause " - DemoFrame.hint([ + Frame.hint([ {" A ", :chip}, {pause_label, :label}, {" B ", :chip}, diff --git a/lib/name_badge/screen/top_level.ex b/lib/name_badge/screen/top_level.ex index c117972..5ee6196 100644 --- a/lib/name_badge/screen/top_level.ex +++ b/lib/name_badge/screen/top_level.ex @@ -16,9 +16,9 @@ defmodule NameBadge.Screen.TopLevel do defp screens do if NameBadge.CalendarService.enabled?() do - # Insert Calendar just before Weather + # Insert Calendar after Weather weather_index = Enum.find_index(@base_screens, &match?({Screen.Weather, _}, &1)) - List.insert_at(@base_screens, weather_index, {Screen.Calendar, "Calendar"}) + List.insert_at(@base_screens, weather_index + 1, {Screen.Calendar, "Calendar"}) else @base_screens end diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index b469737..f27ecb1 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -152,7 +152,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do %{widgets: widgets} do titles = block_titles(widgets) - # Outer DemoFrame block plus one block per section. + # Outer Frame block plus one block per section. assert " ex_ratatui - stats " in titles assert " summary " in titles assert " memory by category " in titles From 86926838c7f03e6b10286184beacfb4ff489d917 Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Mon, 11 May 2026 15:39:59 +0200 Subject: [PATCH 27/30] perf(ex_ratatui): hot-path tweaks + configurability pass --- lib/name_badge/ex_ratatui/frame.ex | 2 +- lib/name_badge/ex_ratatui/raster.ex | 64 ++++++++------- .../ex_ratatui/system_monitor_tui.ex | 82 ++++++++++++------- lib/name_badge/screen/ex_ratatui.ex | 48 ++++++----- lib/name_badge/screen/ex_ratatui/stats.ex | 9 +- .../screen/ex_ratatui/stats_test.exs | 2 +- 6 files changed, 119 insertions(+), 88 deletions(-) diff --git a/lib/name_badge/ex_ratatui/frame.ex b/lib/name_badge/ex_ratatui/frame.ex index 39ff016..b571e73 100644 --- a/lib/name_badge/ex_ratatui/frame.ex +++ b/lib/name_badge/ex_ratatui/frame.ex @@ -3,7 +3,7 @@ defmodule NameBadge.ExRatatui.Frame do Shared chrome for the badge's `ExRatatui.App` demos. Every demo lays out the same way: an outer `Block` titled - ` ex_ratatui · <demo> ` covers all but the bottom row, and a + ` ex_ratatui - <demo> ` covers all but the bottom row, and a single-row hint strip at the very bottom carries reverse-video key chips next to plain action labels. Centralising those rects here keeps the screens visually consistent and means individual diff --git a/lib/name_badge/ex_ratatui/raster.ex b/lib/name_badge/ex_ratatui/raster.ex index 0a1f2c6..fa65667 100644 --- a/lib/name_badge/ex_ratatui/raster.ex +++ b/lib/name_badge/ex_ratatui/raster.ex @@ -105,7 +105,12 @@ defmodule NameBadge.ExRatatui.Raster do """ @spec apply_diff(t(), Diff.t()) :: t() def apply_diff(%__MODULE__{cells: existing} = r, %Diff{ops: ops}) do - %{r | cells: Map.merge(existing, index(ops))} + cells = + Enum.reduce(ops, existing, fn %Cell{col: c, row: row} = cell, acc -> + Map.put(acc, {c, row}, cell) + end) + + %{r | cells: cells} end @doc """ @@ -144,6 +149,25 @@ defmodule NameBadge.ExRatatui.Raster do @paper_cell_row :binary.copy(<<@paper>>, @cell_w) @paper_right_padding :binary.copy(<<@paper>>, @display_width - @grid_cols * @cell_w) + # Precomputed 6-byte pixel rows for every {glyph_byte, inverted?} + # pair. The glyph_byte's top six bits index six pixels; the inverted + # flag swaps which colour fills set vs cleared bits. With 256 × 2 = + # 512 entries this is ~3 KiB resident, and every refresh hits the + # table instead of doing six shifts + a list-to-binary per cell row. + @row_table (for byte <- 0..255, inverted? <- [false, true], into: %{} do + {ink, paper} = if inverted?, do: {@paper, @ink}, else: {@ink, @paper} + + row = + for i <- 0..(@cell_w - 1), into: <<>> do + case byte >>> (7 - i) &&& 1 do + 1 -> <<ink>> + 0 -> <<paper>> + end + end + + {{byte, inverted?}, row} + end) + defp paper_padding(:right), do: @paper_right_padding defp cell_pixel_row(nil, _sub_y), do: @paper_cell_row @@ -156,43 +180,21 @@ defmodule NameBadge.ExRatatui.Raster do |> Font.glyph() |> :binary.at(sub_y) - {ink, paper} = ink_and_paper(cell) - - Enum.map(0..(@cell_w - 1), fn i -> - case byte >>> (7 - i) &&& 1 do - 1 -> ink - 0 -> paper - end - end) - |> :binary.list_to_bin() + Map.fetch!(@row_table, {byte, inverted?(cell)}) end - # Returns the {ink_byte, paper_byte} pair to use for a cell. On the - # 1-bit e-ink display, "color" collapses to "are we inverted?" — a - # cell paints paper glyphs on an ink background only when the user - # explicitly asked for it via the `:reversed` modifier or + # On the 1-bit e-ink display, "color" collapses to "are we inverted?" + # — a cell paints paper glyphs on an ink background only when the + # user explicitly asked for it via the `:reversed` modifier or # `bg: :black`. Other bg colors (notably `:white`, which the Canvas # widget emits as its default fill) leave the cell rendering # ink-on-paper. - defp ink_and_paper(%Cell{bg: bg, modifiers: modifiers}) do - if inverted?(bg, modifiers) do - {@paper, @ink} - else - {@ink, @paper} - end - end - - defp inverted?(:black, _modifiers), do: true - defp inverted?(_bg, modifiers), do: :reversed in modifiers + defp inverted?(%Cell{bg: :black}), do: true + defp inverted?(%Cell{modifiers: modifiers}), do: :reversed in modifiers defp codepoint_of(""), do: ?\s - - defp codepoint_of(symbol) when is_binary(symbol) do - case String.to_charlist(symbol) do - [cp | _] -> cp - [] -> ?\s - end - end + defp codepoint_of(<<cp::utf8, _::binary>>), do: cp + defp codepoint_of(_), do: ?\s defp index(cells) do Map.new(cells, fn %Cell{col: c, row: r} = cell -> {{c, r}, cell} end) diff --git a/lib/name_badge/ex_ratatui/system_monitor_tui.ex b/lib/name_badge/ex_ratatui/system_monitor_tui.ex index 466d71d..2a05177 100644 --- a/lib/name_badge/ex_ratatui/system_monitor_tui.ex +++ b/lib/name_badge/ex_ratatui/system_monitor_tui.ex @@ -59,17 +59,26 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do alias ExRatatui.Widgets.Chart.{Axis, Dataset} alias ExRatatui.Widgets.List, as: WList - @refresh_ms 1_000 - @top_n 20 - @history_size 60 + @default_refresh_ms 1_000 + @default_top_n 20 + @default_history_size 60 # -- Reducer callbacks -- @impl true - def init(_opts) do + def init(opts) do + refresh_ms = Keyword.get(opts, :refresh_ms, @default_refresh_ms) + top_n = Keyword.get(opts, :top_n, @default_top_n) + history_size = Keyword.get(opts, :history_size, @default_history_size) + + # Enables per-scheduler busy-time accounting (read in + # `collect_scheduler_usage/1`). Node-global side effect — intentional; + # leaving it on after this TUI exits is harmless and matches what + # `:observer` / `observer_cli` do. :erlang.system_flag(:scheduler_wall_time, true) + host = collect_host_info() - metrics = collect_metrics(nil) + metrics = collect_metrics(nil, top_n) state = %{ tab: 0, @@ -77,9 +86,12 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do host: host, metrics: metrics, prev_sched_sample: metrics.sched_sample, - ram_history: List.duplicate(0, @history_size), - load_history: List.duplicate({0.0, 0.0, 0.0}, @history_size), - sched_history: List.duplicate(0, @history_size) + refresh_ms: refresh_ms, + top_n: top_n, + history_size: history_size, + ram_history: List.duplicate(0, history_size), + load_history: List.duplicate({0.0, 0.0, 0.0}, history_size), + sched_history: List.duplicate(0, history_size) } {:ok, state} @@ -136,9 +148,11 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do end def update({:info, :refresh}, state) do + %{prev_sched_sample: prev, top_n: top_n} = state + cmd = Command.async( - fn -> collect_metrics(state.prev_sched_sample) end, + fn -> collect_metrics(prev, top_n) end, fn metrics -> {:metrics_collected, metrics} end ) @@ -147,14 +161,16 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do def update({:info, {:metrics_collected, metrics}}, state) do load = metrics.cpu_load + size = state.history_size new_state = %{ state | metrics: metrics, prev_sched_sample: metrics.sched_sample, - ram_history: push_history(state.ram_history, ram_percent(metrics)), - load_history: push_history(state.load_history, {load.load1, load.load5, load.load15}), - sched_history: push_history(state.sched_history, sched_avg_percent(metrics)) + ram_history: push_history(state.ram_history, ram_percent(metrics), size), + load_history: + push_history(state.load_history, {load.load1, load.load5, load.load15}, size), + sched_history: push_history(state.sched_history, sched_avg_percent(metrics), size) } {:noreply, new_state} @@ -163,8 +179,8 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do def update(_msg, state), do: {:noreply, state} @impl true - def subscriptions(_state) do - [Subscription.interval(:refresh, @refresh_ms, :refresh)] + def subscriptions(state) do + [Subscription.interval(:refresh, state.refresh_ms, :refresh)] end # -- Header / Tabs / Footer -- @@ -571,7 +587,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do Line.new([ Span.new(" "), Span.new("Top", style: %Style{fg: :blue, modifiers: [:bold]}), - Span.new(" #{@top_n} "), + Span.new(" #{state.top_n} "), Span.new("by memory", style: %Style{fg: :dark_gray}), Span.new(" ") ]), @@ -634,9 +650,9 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do } ], x_axis: %Axis{ - bounds: {0.0, (@history_size - 1) * 1.0}, + bounds: {0.0, (state.history_size - 1) * 1.0}, style: %Style{fg: :dark_gray}, - labels: [" -#{@history_size}s ", " now "] + labels: [" -#{state.history_size}s ", " now "] }, y_axis: %Axis{ title: Span.new("%", style: %Style{fg: :dark_gray}), @@ -654,7 +670,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do Span.new("#{ram_percent(state.metrics)}%", style: %Style{fg: :cyan, modifiers: [:bold]} ), - Span.new(" — last #{@history_size}s ", style: %Style{fg: :dark_gray}) + Span.new(" — last #{state.history_size}s ", style: %Style{fg: :dark_gray}) ]), borders: [:all], border_type: :rounded, @@ -664,14 +680,16 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do end defp load_chart(state) do + history = Enum.reverse(state.load_history) + load1 = - state.load_history |> Enum.with_index() |> Enum.map(fn {{v, _, _}, i} -> {i * 1.0, v} end) + history |> Enum.with_index() |> Enum.map(fn {{v, _, _}, i} -> {i * 1.0, v} end) load5 = - state.load_history |> Enum.with_index() |> Enum.map(fn {{_, v, _}, i} -> {i * 1.0, v} end) + history |> Enum.with_index() |> Enum.map(fn {{_, v, _}, i} -> {i * 1.0, v} end) load15 = - state.load_history |> Enum.with_index() |> Enum.map(fn {{_, _, v}, i} -> {i * 1.0, v} end) + history |> Enum.with_index() |> Enum.map(fn {{_, _, v}, i} -> {i * 1.0, v} end) cores = max(state.host.cpu_cores || 1, 1) y_max = max(cores * 1.5, highest_load(state.load_history) * 1.1) @@ -702,9 +720,9 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do } ], x_axis: %Axis{ - bounds: {0.0, (@history_size - 1) * 1.0}, + bounds: {0.0, (state.history_size - 1) * 1.0}, style: %Style{fg: :dark_gray}, - labels: [" -#{@history_size}s ", " now "] + labels: [" -#{state.history_size}s ", " now "] }, y_axis: %Axis{ bounds: {0.0, y_max}, @@ -737,7 +755,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do defp sched_sparkline(state) do %Sparkline{ - data: state.sched_history, + data: Enum.reverse(state.sched_history), max: 100, bar_set: :nine_levels, style: %Style{fg: :green}, @@ -750,7 +768,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do Span.new("#{sched_avg_percent(state.metrics)}%", style: %Style{fg: :green, modifiers: [:bold]} ), - Span.new(" — last #{@history_size}s ", style: %Style{fg: :dark_gray}) + Span.new(" — last #{state.history_size}s ", style: %Style{fg: :dark_gray}) ]), borders: [:all], border_type: :rounded, @@ -762,7 +780,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do # -- Metrics collection (runs in Command.async) -- @doc false - def collect_metrics(prev_sched_sample) do + def collect_metrics(prev_sched_sample, top_n \\ @default_top_n) do {scheduler_usage, new_sample} = collect_scheduler_usage(prev_sched_sample) beam = :erlang.memory() @@ -771,7 +789,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do sys: collect_system_info(scheduler_usage), host_uptime: read_host_uptime(), cpu_load: read_cpu_load(), - top_procs: collect_top_processes(@top_n), + top_procs: collect_top_processes(top_n), sched_sample: new_sample } end @@ -1024,13 +1042,17 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do # -- History helpers -- - defp push_history(list, value) do - [_ | rest] = list - rest ++ [value] + # Internally newest-first: prepend O(1), truncate via pattern match. + # Consumers that need chronological order (`indexed_points/1`) reverse + # at read time — paid once per render, vs once per tick across three + # histories. + defp push_history(history, value, size) do + [value | Enum.take(history, size - 1)] end defp indexed_points(list) do list + |> Enum.reverse() |> Enum.with_index() |> Enum.map(fn {v, i} -> {i * 1.0, v * 1.0} end) end diff --git a/lib/name_badge/screen/ex_ratatui.ex b/lib/name_badge/screen/ex_ratatui.ex index 8229ef9..f219e00 100644 --- a/lib/name_badge/screen/ex_ratatui.ex +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -174,9 +174,16 @@ defmodule NameBadge.Screen.ExRatatui do defp blank_png(), do: Raster.new() |> Raster.to_png() # Awaits the first cell_writer message and folds it into the - # provided raster. Falls back to a blank PNG if no diff is in the - # mailbox after a short window — better to start with blank and - # update on the next handle_info than to deadlock the screen. + # provided raster. Falls back to a blank PNG if no diff is + # delivered within @initial_frame_timeout — better to start with + # blank and update on the next handle_info than to deadlock the + # screen. + # + # 100ms is comfortably above the observed first-render time + # (synchronous in the Server's init/1 plus a single send) on the + # badge — even under cold load the first diff has always landed + # well under that. Bump if a future app does heavy work in its + # mount/1. @initial_frame_timeout 100 defp drain_initial_frame(raster) do receive do @@ -211,27 +218,26 @@ defmodule NameBadge.Screen.ExRatatui do end defp ensure_ex_ratatui_app!(app_mod) do - Code.ensure_loaded(app_mod) - - cond do - not Code.ensure_loaded?(app_mod) -> + case Code.ensure_loaded(app_mod) do + {:module, ^app_mod} -> + if function_exported?(app_mod, :__runtime__, 0) do + :ok + else + raise ArgumentError, """ + #{inspect(app_mod)} does not export __runtime__/0 — did you + forget `use ExRatatui.App`? + + Declaring `@behaviour ExRatatui.App` alone is not enough; the + runtime relies on __runtime__/0 to choose between the callback + and reducer styles. Switch to `use ExRatatui.App` and the + function will be injected for you. + """ + end + + {:error, _reason} -> raise ArgumentError, "App module #{inspect(app_mod)} could not be loaded. " <> "Check the spelling and make sure it compiles." - - not function_exported?(app_mod, :__runtime__, 0) -> - raise ArgumentError, """ - #{inspect(app_mod)} does not export __runtime__/0 — did you - forget `use ExRatatui.App`? - - Declaring `@behaviour ExRatatui.App` alone is not enough; the - runtime relies on __runtime__/0 to choose between the callback - and reducer styles. Switch to `use ExRatatui.App` and the - function will be injected for you. - """ - - true -> - :ok end end diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index 6abc555..8e0e78e 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -216,10 +216,11 @@ defmodule NameBadge.Screen.ExRatatui.Stats do } end + # Internally newest-first: prepend O(1), truncate via Enum.take. + # Sparklines render oldest-on-the-left, so callers reverse at read + # time — see `trends_section/2`. defp push_history(history, value) do - history - |> Enum.take(@history_len - 1) - |> List.insert_at(-1, value) + [value | Enum.take(history, @history_len - 1)] end defp cycle_metric(metric) do @@ -355,7 +356,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do [ {%Paragraph{text: label}, %Rect{x: inner.x, y: inner.y + dy, width: label_w, height: 1}}, - {%Sparkline{data: data, bar_set: @bar_set}, + {%Sparkline{data: Enum.reverse(data), bar_set: @bar_set}, %Rect{x: spark_x, y: inner.y + dy, width: spark_w, height: 1}} ] end) diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index f27ecb1..44ba43d 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -94,7 +94,7 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do [state: state] end - test "appends a new sample to all three histories", %{state: state} do + test "adds a new sample to all three histories", %{state: state} do assert {:noreply, after_tick} = Stats.update({:info, :refresh}, state) assert length(after_tick.memory_history) == length(state.memory_history) + 1 From 664935043688c0d02a5979c86fb2671bff1f273b Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Mon, 11 May 2026 15:50:45 +0200 Subject: [PATCH 28/30] refactor(ex_ratatui): precompute Goathi art + tighten SystemMonitor loop --- lib/name_badge/ex_ratatui/frame.ex | 4 +- .../ex_ratatui/system_monitor_tui.ex | 20 +++++- lib/name_badge/screen/ex_ratatui/goathi.ex | 65 ++++++------------- .../screen/ex_ratatui/goathi/art.ex | 37 +++++++++++ .../screen/ex_ratatui/goathi_test.exs | 7 +- 5 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 lib/name_badge/screen/ex_ratatui/goathi/art.ex diff --git a/lib/name_badge/ex_ratatui/frame.ex b/lib/name_badge/ex_ratatui/frame.ex index b571e73..2b11993 100644 --- a/lib/name_badge/ex_ratatui/frame.ex +++ b/lib/name_badge/ex_ratatui/frame.ex @@ -57,7 +57,9 @@ defmodule NameBadge.ExRatatui.Frame do @spec layout(String.t(), sized()) :: {Block.t(), Rect.t(), Rect.t(), Rect.t()} def layout(title, %{width: width, height: height}) - when is_binary(title) and is_integer(width) and is_integer(height) do + when is_binary(title) and + is_integer(width) and width >= 5 and + is_integer(height) and height >= 3 do block_rect = %Rect{x: 0, y: 0, width: width, height: height - 2} content_rect = %Rect{ diff --git a/lib/name_badge/ex_ratatui/system_monitor_tui.ex b/lib/name_badge/ex_ratatui/system_monitor_tui.ex index 2a05177..7674db3 100644 --- a/lib/name_badge/ex_ratatui/system_monitor_tui.ex +++ b/lib/name_badge/ex_ratatui/system_monitor_tui.ex @@ -89,6 +89,7 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do refresh_ms: refresh_ms, top_n: top_n, history_size: history_size, + in_flight?: false, ram_history: List.duplicate(0, history_size), load_history: List.duplicate({0.0, 0.0, 0.0}, history_size), sched_history: List.duplicate(0, history_size) @@ -147,6 +148,15 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do {:noreply, %{state | selected: max(state.selected - 1, 0)}} end + def update({:info, :refresh}, %{in_flight?: true} = state) do + # Previous collection still running. Drop this tick instead of + # queueing a second async — on the badge under load a heavy + # collect_metrics (Process.list + per-process info + /proc reads) + # can occasionally outrun the refresh interval, and queueing would + # turn a momentary spike into a backlog. + {:noreply, state, render?: false} + end + def update({:info, :refresh}, state) do %{prev_sched_sample: prev, top_n: top_n} = state @@ -156,17 +166,25 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do fn metrics -> {:metrics_collected, metrics} end ) - {:noreply, state, commands: [cmd], render?: false} + {:noreply, %{state | in_flight?: true}, commands: [cmd], render?: false} end def update({:info, {:metrics_collected, metrics}}, state) do load = metrics.cpu_load size = state.history_size + # Clamp the highlighted row: a high-memory process can disappear + # between collections, shrinking `top_procs` below the user's + # current `selected` index. Without this clamp the Table widget + # would render a highlight on a row that no longer exists. + selected = min(state.selected, max(length(metrics.top_procs) - 1, 0)) + new_state = %{ state | metrics: metrics, prev_sched_sample: metrics.sched_sample, + selected: selected, + in_flight?: false, ram_history: push_history(state.ram_history, ram_percent(metrics), size), load_history: push_history(state.load_history, {load.load1, load.load5, load.load15}, size), diff --git a/lib/name_badge/screen/ex_ratatui/goathi.ex b/lib/name_badge/screen/ex_ratatui/goathi.ex index 1bff7f4..910f281 100644 --- a/lib/name_badge/screen/ex_ratatui/goathi.ex +++ b/lib/name_badge/screen/ex_ratatui/goathi.ex @@ -8,9 +8,11 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do two ears, and two eyes drawn as filled squares — and a "HI!" pixel-art word in the top-left of the canvas. Every other tick the greeting flashes on and the right eye blinks (drops to a single - dash row), so the goat winks while it speaks. All shapes flow - through `ascii_to_points/3` into a single `Canvas`, layered in - order so the eye sits on top of the face outline. + dash row), so the goat winks while it speaks. Each art heredoc is + parsed once at compile time by `NameBadge.Screen.ExRatatui.Goathi.Art` + into a `%Canvas.Points{}` and baked into a module attribute — render + is then pure assembly of the pre-built shapes layered in order so + the eye sits on top of the face outline. Built on the reducer runtime — one `update/2` clause per `{:event, …}` / `{:info, …}` shape — so it doubles as a tour of @@ -45,8 +47,8 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do alias ExRatatui.Event.Key alias ExRatatui.Subscription alias ExRatatui.Widgets.Canvas - alias ExRatatui.Widgets.Canvas.Points alias NameBadge.ExRatatui.Frame + alias NameBadge.Screen.ExRatatui.Goathi.Art @tick_interval_ms 1_000 @@ -132,6 +134,18 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do @hi_origin_x 2.0 @hi_origin_y_top 32.0 + # The art is static and the origins are compile-time numbers, so the + # resulting `%Points{}` is also static. Bake them once at compile + # time instead of re-parsing the heredocs on every render. + @face_points Art.ascii_to_points(@face_art, @face_origin_x, @face_origin_y) + @hi_points Art.ascii_to_points(@hi_art, @hi_origin_x, @hi_origin_y_top) + @right_eye_open_points Art.ascii_to_points(@right_eye_open, @right_eye_x, @right_eye_y_open) + @right_eye_closed_points Art.ascii_to_points( + @right_eye_closed, + @right_eye_x, + @right_eye_y_closed + ) + @impl ExRatatui.App def init(_opts), do: {:ok, %{tick: 0, paused?: false}} @@ -180,21 +194,12 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do end defp shapes(state) do - face = ascii_to_points(@face_art, @face_origin_x, @face_origin_y) - if hi_visible?(state.tick) do # Speaking + winking: HI! shows, right eye drops to a dash. - [ - ascii_to_points(@hi_art, @hi_origin_x, @hi_origin_y_top), - ascii_to_points(@right_eye_closed, @right_eye_x, @right_eye_y_closed), - face - ] + [@hi_points, @right_eye_closed_points, @face_points] else # Resting: no HI!, right eye fully open. - [ - ascii_to_points(@right_eye_open, @right_eye_x, @right_eye_y_open), - face - ] + [@right_eye_open_points, @face_points] end end @@ -212,36 +217,6 @@ defmodule NameBadge.Screen.ExRatatui.Goathi do def hi_visible?(tick) when is_integer(tick) and tick >= 0, do: rem(tick, 2) == 0 - @doc """ - Walks an ASCII-art string and emits a single - `%ExRatatui.Widgets.Canvas.Points{}` carrying one coordinate per - non-space character. Row 0 of the art lines up with `y_origin`; - each subsequent row sits one canvas-unit below. Designed to be - fed straight into a `:block`-marker `Canvas` whose bounds are - sized 1:1 with cells. - - Public so tests and refinement scripts can call it without - reaching into the module's private API. - """ - @spec ascii_to_points(String.t(), number(), number()) :: Points.t() - def ascii_to_points(art, x_origin, y_origin) when is_binary(art) do - coords = - art - |> String.split("\n") - |> Enum.with_index() - |> Enum.flat_map(fn {row_str, row} -> - row_str - |> String.graphemes() - |> Enum.with_index() - |> Enum.flat_map(fn - {" ", _col} -> [] - {_char, col} -> [{x_origin + col * 1.0, y_origin - row * 1.0}] - end) - end) - - %Points{coords: coords, color: :white} - end - defp hint_paragraph(state) do pause_label = if state.paused?, do: " resume ", else: " pause " diff --git a/lib/name_badge/screen/ex_ratatui/goathi/art.ex b/lib/name_badge/screen/ex_ratatui/goathi/art.ex new file mode 100644 index 0000000..169fabb --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/goathi/art.ex @@ -0,0 +1,37 @@ +defmodule NameBadge.Screen.ExRatatui.Goathi.Art do + @moduledoc """ + Pure helpers for turning ASCII-art strings into + `ExRatatui.Widgets.Canvas.Points` structs. + + Lives in its own module so `NameBadge.Screen.ExRatatui.Goathi` can + evaluate it at compile-time and bake the resulting `%Points{}` into + module attributes — the goat face never changes between renders, so + re-parsing the same heredoc 60 times a minute is pure waste. + """ + + alias ExRatatui.Widgets.Canvas.Points + + @doc """ + Walks an ASCII-art string and emits a `%Points{}` carrying one + coordinate per non-space character. Row 0 of the art lines up with + `y_origin`; each subsequent row sits one canvas-unit below. + """ + @spec ascii_to_points(String.t(), number(), number()) :: Points.t() + def ascii_to_points(art, x_origin, y_origin) when is_binary(art) do + coords = + art + |> String.split("\n") + |> Enum.with_index() + |> Enum.flat_map(fn {row_str, row} -> + row_str + |> String.graphemes() + |> Enum.with_index() + |> Enum.flat_map(fn + {" ", _col} -> [] + {_char, col} -> [{x_origin + col * 1.0, y_origin - row * 1.0}] + end) + end) + + %Points{coords: coords, color: :white} + end +end diff --git a/test/name_badge/screen/ex_ratatui/goathi_test.exs b/test/name_badge/screen/ex_ratatui/goathi_test.exs index 366438e..ae3448e 100644 --- a/test/name_badge/screen/ex_ratatui/goathi_test.exs +++ b/test/name_badge/screen/ex_ratatui/goathi_test.exs @@ -9,6 +9,7 @@ defmodule NameBadge.Screen.ExRatatui.GoathiTest do alias ExRatatui.Widgets.{Block, Canvas, Paragraph} alias ExRatatui.Widgets.Canvas.Points alias NameBadge.Screen.ExRatatui.Goathi + alias NameBadge.Screen.ExRatatui.Goathi.Art describe "init/1" do test "starts at tick 0 and unpaused" do @@ -88,14 +89,14 @@ defmodule NameBadge.Screen.ExRatatui.GoathiTest do end end - describe "ascii_to_points/3" do + describe "Art.ascii_to_points/3" do test "emits one coordinate per non-space character, with row 0 at y_origin" do art = """ ## ### """ - %Points{coords: coords, color: :white} = Goathi.ascii_to_points(art, 0.0, 10.0) + %Points{coords: coords, color: :white} = Art.ascii_to_points(art, 0.0, 10.0) # 5 non-space chars (`##` then `###`). assert length(coords) == 5 @@ -111,7 +112,7 @@ defmodule NameBadge.Screen.ExRatatui.GoathiTest do end test "respects the x and y origins" do - %Points{coords: [{x, y}]} = Goathi.ascii_to_points("#", 7.5, 3.0) + %Points{coords: [{x, y}]} = Art.ascii_to_points("#", 7.5, 3.0) assert {x, y} == {7.5, 3.0} end end From 799cd38d4c6e28c6b9892d51657d51edfdbac33b Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Mon, 11 May 2026 16:09:58 +0200 Subject: [PATCH 29/30] refactor(ex_ratatui): async stats refresh + crash-safe collectors --- .../ex_ratatui/system_monitor_tui.ex | 30 +++++- lib/name_badge/screen/ex_ratatui/stats.ex | 92 +++++++++++++++---- .../screen/ex_ratatui/stats_test.exs | 59 ++++++++++-- 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/lib/name_badge/ex_ratatui/system_monitor_tui.ex b/lib/name_badge/ex_ratatui/system_monitor_tui.ex index 7674db3..23dacc3 100644 --- a/lib/name_badge/ex_ratatui/system_monitor_tui.ex +++ b/lib/name_badge/ex_ratatui/system_monitor_tui.ex @@ -41,6 +41,8 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do use ExRatatui.App, runtime: :reducer + require Logger + alias ExRatatui.{Command, Event, Layout, Layout.Rect, Style, Subscription} alias ExRatatui.Text.{Line, Span} @@ -162,13 +164,23 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do cmd = Command.async( - fn -> collect_metrics(prev, top_n) end, - fn metrics -> {:metrics_collected, metrics} end + fn -> safe_collect_metrics(prev, top_n) end, + fn + :error -> :collect_failed + metrics -> {:metrics_collected, metrics} + end ) {:noreply, %{state | in_flight?: true}, commands: [cmd], render?: false} end + def update({:info, :collect_failed}, state) do + # Strand-prevention: a crashed collect_metrics shouldn't leave + # `in_flight?` stuck at true and freeze the dashboard. The error + # itself has already been logged by `safe_collect_metrics/2`. + {:noreply, %{state | in_flight?: false}, render?: false} + end + def update({:info, {:metrics_collected, metrics}}, state) do load = metrics.cpu_load size = state.history_size @@ -797,6 +809,20 @@ defmodule NameBadge.ExRatatui.SystemMonitorTui do # -- Metrics collection (runs in Command.async) -- + # Belt-and-braces around `collect_metrics/2` for the async path: a + # crashed collection shouldn't permanently strand `in_flight?` at + # true and freeze the dashboard. Direct callers (tests, IEx) don't + # need this — they want exceptions to propagate. + defp safe_collect_metrics(prev_sched_sample, top_n) do + try do + collect_metrics(prev_sched_sample, top_n) + rescue + e -> + Logger.error("collect_metrics crashed: #{Exception.message(e)}") + :error + end + end + @doc false def collect_metrics(prev_sched_sample, top_n \\ @default_top_n) do {scheduler_usage, new_sample} = collect_scheduler_usage(prev_sched_sample) diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index 8e0e78e..9486ba6 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -64,6 +64,9 @@ defmodule NameBadge.Screen.ExRatatui.Stats do use ExRatatui.App, runtime: :reducer + require Logger + + alias ExRatatui.Command alias ExRatatui.Event.Key alias ExRatatui.Layout.Rect alias ExRatatui.Subscription @@ -107,6 +110,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do @typedoc false @type state :: %{ paused?: boolean(), + in_flight?: boolean(), metric: metric(), last_reductions: non_neg_integer() | nil, memory_history: [non_neg_integer()], @@ -120,6 +124,7 @@ defmodule NameBadge.Screen.ExRatatui.Stats do {:ok, %{ paused?: false, + in_flight?: false, metric: :reductions, last_reductions: nil, memory_history: [], @@ -166,7 +171,35 @@ defmodule NameBadge.Screen.ExRatatui.Stats do end def update({:info, :refresh}, %{paused?: true} = state), do: {:noreply, state} - def update({:info, :refresh}, state), do: {:noreply, refresh(state)} + def update({:info, :refresh}, %{in_flight?: true} = state), do: {:noreply, state} + + def update({:info, :refresh}, state) do + # Periodic ticks go through Command.async so a slow Process.list / + # Process.info scan never blocks the reducer. Event-triggered + # refreshes (down/home) stay synchronous because they're rare and + # the user expects an immediate response. + metric = state.metric + last_reductions = state.last_reductions + + cmd = + Command.async( + fn -> safe_sample(metric, last_reductions) end, + fn + :error -> :sample_failed + sample -> {:sample_taken, sample} + end + ) + + {:noreply, %{state | in_flight?: true}, commands: [cmd], render?: false} + end + + def update({:info, {:sample_taken, sample}}, state) do + {:noreply, fold_sample(state, sample) |> Map.put(:in_flight?, false)} + end + + def update({:info, :sample_failed}, state) do + {:noreply, %{state | in_flight?: false}} + end def update(_msg, state), do: {:noreply, state} @@ -175,44 +208,68 @@ defmodule NameBadge.Screen.ExRatatui.Stats do [Subscription.interval(:stats_refresh, @refresh_interval_ms, :refresh)] end - # Pure refresh: takes a state, samples the BEAM, returns the new state. + # Synchronous sample + fold. Used by init/1 and event handlers where + # the caller wants the state updated immediately. Periodic ticks go + # through `Command.async` (see the `:refresh` update clause). @doc false def refresh(state) do + sample = take_sample(state.metric, state.last_reductions) + fold_sample(state, sample) + end + + # Snapshots the BEAM into a `sample` plus a `:total_reductions` field + # the fold needs to compute the next `reds_delta`. Pure data — no + # state mutation — so it can run in `Command.async` without sharing + # anything with the reducer process. + defp take_sample(metric, prev_reductions) do {uptime_ms, _} = :erlang.statistics(:wall_clock) mem = Map.new(:erlang.memory()) memory_kib = div(mem.total, 1024) {total_reds, _} = :erlang.statistics(:reductions) reds_delta = - case state.last_reductions do + case prev_reductions do nil -> 0 prev -> max(total_reds - prev, 0) end - procs = length(Process.list()) - queue_len = :erlang.statistics(:total_run_queue_lengths) - top = top_processes(state.metric) - - sample = %{ + %{ uptime_ms: uptime_ms, memory_kib: memory_kib, reds_delta: reds_delta, - procs: procs, + total_reductions: total_reds, + procs: :erlang.system_info(:process_count), proc_limit: :erlang.system_info(:process_limit), atom_count: :erlang.system_info(:atom_count), atom_limit: :erlang.system_info(:atom_limit), - queue_len: queue_len, + queue_len: :erlang.statistics(:total_run_queue_lengths), mem_breakdown: Map.take(mem, [:processes, :binary, :ets, :code, :atom]), - top: top + top: top_processes(metric) } + end + # Belt-and-braces around `take_sample/2` for the async path: a + # crashed sample shouldn't permanently strand `in_flight?` at `true` + # and freeze the dashboard. The synchronous callers don't need this + # — an exception there propagates as it would have before. + defp safe_sample(metric, prev_reductions) do + try do + take_sample(metric, prev_reductions) + rescue + e -> + Logger.error("Stats.take_sample crashed: #{Exception.message(e)}") + :error + end + end + + defp fold_sample(state, sample) do %{ state - | last_reductions: total_reds, - memory_history: push_history(state.memory_history, memory_kib), - reds_history: push_history(state.reds_history, reds_delta), - queue_history: push_history(state.queue_history, queue_len), - sample: sample + | last_reductions: sample.total_reductions, + memory_history: push_history(state.memory_history, sample.memory_kib), + reds_history: push_history(state.reds_history, sample.reds_delta), + queue_history: push_history(state.queue_history, sample.queue_len), + sample: Map.delete(sample, :total_reductions) } end @@ -254,6 +311,9 @@ defmodule NameBadge.Screen.ExRatatui.Stats do name when is_atom(name) -> Atom.to_string(name) + + _ -> + "—" end end diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index 44ba43d..811d39b 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -91,15 +91,37 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do describe "update/2 — refresh ticks" do setup do {:ok, state} = Stats.init([]) - [state: state] + [state: state, sample: fixture_sample()] + end + + test ":refresh kicks off an async sample and flips in_flight?", %{state: state} do + assert {:noreply, after_tick, opts} = Stats.update({:info, :refresh}, state) + assert after_tick.in_flight? == true + assert opts[:render?] == false + assert is_list(opts[:commands]) and opts[:commands] != [] end - test "adds a new sample to all three histories", %{state: state} do - assert {:noreply, after_tick} = Stats.update({:info, :refresh}, state) + test ":sample_taken folds into all three histories and resets in_flight?", + %{state: state, sample: sample} do + in_flight = %{state | in_flight?: true} + + assert {:noreply, after_fold} = Stats.update({:info, {:sample_taken, sample}}, in_flight) - assert length(after_tick.memory_history) == length(state.memory_history) + 1 - assert length(after_tick.reds_history) == length(state.reds_history) + 1 - assert length(after_tick.queue_history) == length(state.queue_history) + 1 + assert after_fold.in_flight? == false + assert length(after_fold.memory_history) == length(state.memory_history) + 1 + assert length(after_fold.reds_history) == length(state.reds_history) + 1 + assert length(after_fold.queue_history) == length(state.queue_history) + 1 + end + + test ":sample_failed resets in_flight? without touching histories", %{state: state} do + in_flight = %{state | in_flight?: true} + + assert {:noreply, after_fail} = Stats.update({:info, :sample_failed}, in_flight) + + assert after_fail.in_flight? == false + assert after_fail.memory_history == state.memory_history + assert after_fail.reds_history == state.reds_history + assert after_fail.queue_history == state.queue_history end test "is a no-op when paused", %{state: state} do @@ -107,10 +129,15 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert {:noreply, ^paused} = Stats.update({:info, :refresh}, paused) end - test "history is capped at 50 samples", %{state: state} do + test "drops :refresh ticks while a sample is still in flight", %{state: state} do + in_flight = %{state | in_flight?: true} + assert {:noreply, ^in_flight} = Stats.update({:info, :refresh}, in_flight) + end + + test "history is capped at 50 samples", %{state: state, sample: sample} do saturated = Enum.reduce(1..60, state, fn _, acc -> - {:noreply, next} = Stats.update({:info, :refresh}, acc) + {:noreply, next} = Stats.update({:info, {:sample_taken, sample}}, acc) next end) @@ -122,6 +149,22 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do test "ignores unrelated info messages", %{state: state} do assert {:noreply, ^state} = Stats.update({:info, :nope}, state) end + + defp fixture_sample do + %{ + uptime_ms: 1_000, + memory_kib: 100, + reds_delta: 50, + total_reductions: 50, + procs: 5, + proc_limit: 262_144, + atom_count: 100, + atom_limit: 1_048_576, + queue_len: 0, + mem_breakdown: %{processes: 1, binary: 1, ets: 1, code: 1, atom: 1}, + top: [] + } + end end describe "subscriptions/1" do From a3dce93041bbf48aeb8186d52c32911c8c33e63e Mon Sep 17 00:00:00 2001 From: Mauricio Cassola <mauricass19@gmail.com> Date: Mon, 11 May 2026 16:15:08 +0200 Subject: [PATCH 30/30] fix(ex_ratatui): honour Stats pause for samples already in flight --- lib/name_badge/screen/ex_ratatui/counter.ex | 2 +- lib/name_badge/screen/ex_ratatui/stats.ex | 7 +++++++ test/name_badge/screen/ex_ratatui/stats_test.exs | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/name_badge/screen/ex_ratatui/counter.ex b/lib/name_badge/screen/ex_ratatui/counter.ex index 2571142..f6dba0a 100644 --- a/lib/name_badge/screen/ex_ratatui/counter.ex +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -6,7 +6,7 @@ defmodule NameBadge.Screen.ExRatatui.Counter do ## Layout - ┌─ ex_ratatui · counter ───────────────────────┐ + ┌─ ex_ratatui - counter ───────────────────────┐ │ │ │ │ │ count: 42 │ diff --git a/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex index 9486ba6..9d463b7 100644 --- a/lib/name_badge/screen/ex_ratatui/stats.ex +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -193,6 +193,13 @@ defmodule NameBadge.Screen.ExRatatui.Stats do {:noreply, %{state | in_flight?: true}, commands: [cmd], render?: false} end + def update({:info, {:sample_taken, _sample}}, %{paused?: true} = state) do + # A sample that was already in flight when the user pressed pause + # still arrives; honour the pause by discarding it. Without this + # the dashboard advances one tick past where the user froze it. + {:noreply, %{state | in_flight?: false}} + end + def update({:info, {:sample_taken, sample}}, state) do {:noreply, fold_sample(state, sample) |> Map.put(:in_flight?, false)} end diff --git a/test/name_badge/screen/ex_ratatui/stats_test.exs b/test/name_badge/screen/ex_ratatui/stats_test.exs index 811d39b..2d5e6fa 100644 --- a/test/name_badge/screen/ex_ratatui/stats_test.exs +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -134,6 +134,20 @@ defmodule NameBadge.Screen.ExRatatui.StatsTest do assert {:noreply, ^in_flight} = Stats.update({:info, :refresh}, in_flight) end + test ":sample_taken arriving after pause discards the sample but resets in_flight?", + %{state: state, sample: sample} do + paused_in_flight = %{state | paused?: true, in_flight?: true} + + assert {:noreply, after_late_sample} = + Stats.update({:info, {:sample_taken, sample}}, paused_in_flight) + + assert after_late_sample.in_flight? == false + assert after_late_sample.paused? == true + assert after_late_sample.memory_history == state.memory_history + assert after_late_sample.reds_history == state.reds_history + assert after_late_sample.queue_history == state.queue_history + end + test "history is capped at 50 samples", %{state: state, sample: sample} do saturated = Enum.reduce(1..60, state, fn _, acc ->