diff --git a/.gitignore b/.gitignore index 9e19ebd..69f886b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ erl_crash.dump .mise.local.toml # ignore macOS files -.DS_Store \ No newline at end of file +.DS_Store diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..d567976 --- /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.ExRatatui.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.ExRatatui.SystemMonitorTui) + ] +end diff --git a/config/target.exs b/config/target.exs index a7a113f..ca639c2 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 # via fwup subsystem +# ssh -t nerves@wisteria.local \ # live system monitor TUI +# -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 +# 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/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/ex_ratatui/font.ex b/lib/name_badge/ex_ratatui/font.ex new file mode 100644 index 0000000..bff24f0 --- /dev/null +++ b/lib/name_badge/ex_ratatui/font.ex @@ -0,0 +1,1152 @@ +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. 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 + + 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 + @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 => """ + ##### + ....# + ...#. + ..#.. + .#... + #.... + ##### + """, + ?[ => """ + .###. + .#... + .#... + .#... + .#... + .#... + .###. + """, + ?\\ => """ + #.... + #.... + .#... + ..#.. + ..#.. + ...#. + ....# + """, + ?] => """ + .###. + ...#. + ...#. + ...#. + ...#. + ...#. + .###. + """, + ?^ => """ + ..#.. + .#.#. + #...# + ..... + ..... + ..... + ..... + """, + ?_ => """ + ..... + ..... + ..... + ..... + ..... + ..... + ##### + """, + ?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} -> + raw_rows = String.split(art, "\n", trim: true) + + row_chars = + Enum.map(raw_rows, fn line -> + chars = String.graphemes(line) + + 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(six_chars, fn + "#" -> 1 + "." -> 0 + end) + + <> + end) + + 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) + + @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/lib/name_badge/ex_ratatui/frame.ex b/lib/name_badge/ex_ratatui/frame.ex new file mode 100644 index 0000000..2b11993 --- /dev/null +++ b/lib/name_badge/ex_ratatui/frame.ex @@ -0,0 +1,133 @@ +defmodule NameBadge.ExRatatui.Frame 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} = Frame.layout("counter", frame) + + [ + {block, block_rect}, + {my_content_widget, content_rect}, + {Frame.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 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{ + 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 diff --git a/lib/name_badge/ex_ratatui/raster.ex b/lib/name_badge/ex_ratatui/raster.ex new file mode 100644 index 0000000..fa65667 --- /dev/null +++ b/lib/name_badge/ex_ratatui/raster.ex @@ -0,0 +1,202 @@ +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 + + 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. + """ + + 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 + 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 """ + 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) + + # 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 + defp cell_pixel_row(%Cell{skip: true}, _sub_y), do: @paper_cell_row + + defp cell_pixel_row(%Cell{symbol: symbol} = cell, sub_y) do + byte = + symbol + |> codepoint_of() + |> Font.glyph() + |> :binary.at(sub_y) + + Map.fetch!(@row_table, {byte, inverted?(cell)}) + end + + # 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 inverted?(%Cell{bg: :black}), do: true + defp inverted?(%Cell{modifiers: modifiers}), do: :reversed in modifiers + + defp codepoint_of(""), do: ?\s + 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) + end +end diff --git a/lib/name_badge/ex_ratatui/system_monitor_tui.ex b/lib/name_badge/ex_ratatui/system_monitor_tui.ex new file mode 100644 index 0000000..23dacc3 --- /dev/null +++ b/lib/name_badge/ex_ratatui/system_monitor_tui.ex @@ -0,0 +1,1230 @@ +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 + 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.ExRatatui.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.ExRatatui.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 + + require Logger + + 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 + + @default_refresh_ms 1_000 + @default_top_n 20 + @default_history_size 60 + + # -- Reducer callbacks -- + + @impl true + 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, top_n) + + state = %{ + tab: 0, + selected: 0, + host: host, + metrics: metrics, + prev_sched_sample: metrics.sched_sample, + 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) + } + + {: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}, %{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 + + cmd = + Command.async( + 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 + + # 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), + sched_history: push_history(state.sched_history, sched_avg_percent(metrics), size) + } + + {:noreply, new_state} + end + + def update(_msg, state), do: {:noreply, state} + + @impl true + def subscriptions(state) do + [Subscription.interval(:refresh, state.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(" #{state.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, (state.history_size - 1) * 1.0}, + style: %Style{fg: :dark_gray}, + labels: [" -#{state.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 #{state.history_size}s ", style: %Style{fg: :dark_gray}) + ]), + borders: [:all], + border_type: :rounded, + border_style: %Style{fg: :blue} + } + } + end + + defp load_chart(state) do + history = Enum.reverse(state.load_history) + + load1 = + history |> Enum.with_index() |> Enum.map(fn {{v, _, _}, i} -> {i * 1.0, v} end) + + load5 = + history |> Enum.with_index() |> Enum.map(fn {{_, v, _}, i} -> {i * 1.0, v} end) + + load15 = + 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, (state.history_size - 1) * 1.0}, + style: %Style{fg: :dark_gray}, + labels: [" -#{state.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: Enum.reverse(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 #{state.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) -- + + # 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) + 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 -- + + # 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 + + 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 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/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.ex b/lib/name_badge/screen/ex_ratatui.ex new file mode 100644 index 0000000..f219e00 --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui.ex @@ -0,0 +1,301 @@ +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; 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 | + | ----------------------- | ------------------- | + | 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 + + require Logger + + alias ExRatatui.CellSession + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Widgets.Paragraph + 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) + + 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) + + 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) + + # 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) + |> 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} -> + 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( + {: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 + 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() + + # Awaits the first cell_writer message and folds it into the + # 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 + {: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 + 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." + end + end + + @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/ex_ratatui/README.md b/lib/name_badge/screen/ex_ratatui/README.md new file mode 100644 index 0000000..54f008d --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/README.md @@ -0,0 +1,133 @@ +# 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 and monitor the badge. + +## 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.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 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 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 + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.Event.Key + alias ExRatatui.Widgets.Paragraph + 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} = 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}, + {Frame.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 ex_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. + +### 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. 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. + +## The shared chrome — `Frame` + +`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} = 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 `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/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.ExRatatui.SystemMonitorTui) + ] +``` + +To connect: + +```sh +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 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`: + +- **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.ExRatatui.YourTui) +``` + +And then: + +```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 new file mode 100644 index 0000000..f6dba0a --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/counter.ex @@ -0,0 +1,67 @@ +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.Frame`. + + ## Layout + + ┌─ ex_ratatui - counter ───────────────────────┐ + │ │ + │ │ + │ count: 42 │ + │ │ + │ │ + └──────────────────────────────────────────────┘ + + [ A ] +1 [ A long ] reset [ B ] -1 [ B long ] back + + ## Controls + + | 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`) | + """ + + use ExRatatui.App + + alias ExRatatui.Event.Key + alias ExRatatui.Widgets.Paragraph + 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} = Frame.layout("counter", frame) + count_rect = Frame.center_row(content_rect, 1) + + [ + {block, block_rect}, + {%Paragraph{text: "count: #{state.count}", alignment: :center}, count_rect}, + {Frame.hint([ + {" A ", :chip}, + {" +1 ", :label}, + {" A long ", :chip}, + {" reset ", :label}, + {" B ", :chip}, + {" -1 ", :label}, + {" B long ", :chip}, + {" back", :label} + ]), hint_rect} + ] + 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/lib/name_badge/screen/ex_ratatui/goathi.ex b/lib/name_badge/screen/ex_ratatui/goathi.ex new file mode 100644 index 0000000..910f281 --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/goathi.ex @@ -0,0 +1,232 @@ +defmodule NameBadge.Screen.ExRatatui.Goathi do + @moduledoc """ + 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 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. 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 + how to write a self-ticking ExRatatui app. Chrome (outer block + + 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 + 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 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 blink | + | `home` | A (long press) | Reset to tick 0 | + | — | B (long press) | Back to menu (handled by `NameBadge.Screen`) | + """ + + use ExRatatui.App, runtime: :reducer + + alias ExRatatui.Event.Key + alias ExRatatui.Subscription + alias ExRatatui.Widgets.Canvas + alias NameBadge.ExRatatui.Frame + alias NameBadge.Screen.ExRatatui.Goathi.Art + + @tick_interval_ms 1_000 + + # 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 """ + ## ## + #### #### + #### #### + ###################### + ###################### + ###### ###### + ##### ##### + #### ### #### + ### ### ### + ## ## + ## ## + ## ########## ## + ## ## ## ## + ## ## ## ## + ## ## ## + ## ## + ## ## + ## ## + ## ## + #### + ## + """ + + # 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 """ + ### + ### + """ + + # 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 face. Sits in + # the top-left of the canvas where the face never reaches. + @hi_art """ + ## ## #### ## + ## ## ## ## + ###### ## ## + ## ## ## + ## ## #### ## + """ + + # 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. 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 + + # 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}} + + @impl ExRatatui.App + def render(state, frame) do + {_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 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. + canvas = %Canvas{ + x_bounds: {0.0, content_rect.width * 1.0}, + y_bounds: {0.0, content_rect.height * 1.0}, + marker: :block, + shapes: shapes(state), + block: Frame.title_block("goathi") + } + + [ + {canvas, block_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, @tick_interval_ms, :tick)] + end + + defp shapes(state) do + if hi_visible?(state.tick) do + # Speaking + winking: HI! shows, right eye drops to a dash. + [@hi_points, @right_eye_closed_points, @face_points] + else + # Resting: no HI!, right eye fully open. + [@right_eye_open_points, @face_points] + end + end + + @doc """ + 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, + do: rem(tick, 2) == 0 + + defp hint_paragraph(state) do + pause_label = if state.paused?, do: " resume ", else: " pause " + + Frame.hint([ + {" A ", :chip}, + {pause_label, :label}, + {" A long ", :chip}, + {" reset ", :label}, + {" B long ", :chip}, + {" back", :label} + ]) + end +end 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/lib/name_badge/screen/ex_ratatui/stats.ex b/lib/name_badge/screen/ex_ratatui/stats.ex new file mode 100644 index 0000000..9d463b7 --- /dev/null +++ b/lib/name_badge/screen/ex_ratatui/stats.ex @@ -0,0 +1,537 @@ +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 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 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. + + ## 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 + + require Logger + + alias ExRatatui.Command + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Subscription + alias ExRatatui.Widgets.{Block, Paragraph, Sparkline} + alias NameBadge.ExRatatui.Frame + + @history_len 50 + @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 + + @typedoc false + @type sample :: %{ + uptime_ms: non_neg_integer(), + 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()}] + } + + @typedoc false + @type state :: %{ + paused?: boolean(), + in_flight?: boolean(), + metric: metric(), + last_reductions: non_neg_integer() | nil, + memory_history: [non_neg_integer()], + reds_history: [non_neg_integer()], + queue_history: [non_neg_integer()], + sample: sample() | nil + } + + @impl ExRatatui.App + def init(_opts) do + {:ok, + %{ + paused?: false, + in_flight?: false, + metric: :reductions, + last_reductions: nil, + memory_history: [], + reds_history: [], + queue_history: [], + sample: nil + } + |> refresh()} + end + + @impl ExRatatui.App + def render(state, frame) do + {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 + # 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]) + + [ + {block, block_rect}, + {hint_paragraph(state), hint_rect} + ] + |> 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 + 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: [], queue_history: [], last_reductions: nil} + |> refresh()} + end + + def update({:info, :refresh}, %{paused?: true} = state), do: {:noreply, 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}}, %{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 + + def update({:info, :sample_failed}, state) do + {:noreply, %{state | in_flight?: false}} + end + + def update(_msg, state), do: {:noreply, state} + + @impl ExRatatui.App + def subscriptions(_state) do + [Subscription.interval(:stats_refresh, @refresh_interval_ms, :refresh)] + end + + # 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 prev_reductions do + nil -> 0 + prev -> max(total_reds - prev, 0) + end + + %{ + uptime_ms: uptime_ms, + memory_kib: memory_kib, + reds_delta: reds_delta, + 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: :erlang.statistics(:total_run_queue_lengths), + mem_breakdown: Map.take(mem, [:processes, :binary, :ets, :code, :atom]), + 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: 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 + + # 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 + [value | Enum.take(history, @history_len - 1)] + 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 + + # ── Section builders ─────────────────────────────────────────────── + # + # Each section paints its own titled `Block` over a rect carved out + # 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. + + 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: Enum.reverse(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) + 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, + 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 " + + Frame.hint([ + {" A ", :chip}, + {pause_label, :label}, + {" B ", :chip}, + {" cycle ", :label}, + {" B long ", :chip}, + {" back", :label} + ]) + end +end diff --git a/lib/name_badge/screen/goathi.ex b/lib/name_badge/screen/goathi.ex new file mode 100644 index 0000000..e790d5c --- /dev/null +++ b/lib/name_badge/screen/goathi.ex @@ -0,0 +1,9 @@ +defmodule NameBadge.Screen.Goathi do + @moduledoc """ + 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.Goathi +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/lib/name_badge/screen/top_level.ex b/lib/name_badge/screen/top_level.ex index 4fd787f..5ee6196 100644 --- a/lib/name_badge/screen/top_level.ex +++ b/lib/name_badge/screen/top_level.ex @@ -7,6 +7,9 @@ defmodule NameBadge.Screen.TopLevel do {Screen.NameBadge, "Name Badge"}, {Screen.Gallery, "Gallery"}, {Screen.Snake, "Snake"}, + {Screen.Counter, "Counter"}, + {Screen.Goathi, "Goathi"}, + {Screen.Stats, "Stats"}, {Screen.Weather, "Weather"}, {Screen.Settings, "Device Settings"} ] @@ -14,7 +17,8 @@ defmodule NameBadge.Screen.TopLevel do defp screens do if NameBadge.CalendarService.enabled?() do # Insert Calendar after Weather - List.insert_at(@base_screens, 4, {Screen.Calendar, "Calendar"}) + weather_index = Enum.find_index(@base_screens, &match?({Screen.Weather, _}, &1)) + List.insert_at(@base_screens, weather_index + 1, {Screen.Calendar, "Calendar"}) else @base_screens end 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"}, 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..c0dc594 --- /dev/null +++ b/test/name_badge/ex_ratatui/font_test.exs @@ -0,0 +1,131 @@ +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 "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(), + <<row::8>> <- :binary.bin_to_list(Font.glyph(codepoint)) |> Enum.map(&<<&1>>) do + assert Bitwise.band(row, 0b11) == 0, + "glyph #{inspect(codepoint)} has unexpected ink in the unused bottom 2 bits: 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 + + 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, 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 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 + + 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 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..d5da6ae --- /dev/null +++ b/test/name_badge/ex_ratatui/raster_test.exs @@ -0,0 +1,209 @@ +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 "style: inversion" do + 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() + |> 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 "`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() + |> 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 = + 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 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..41bdb82 --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/counter_test.exs @@ -0,0 +1,97 @@ +defmodule NameBadge.Screen.ExRatatui.CounterTest do + use ExUnit.Case, async: true + + alias ExRatatui.Event.Key + alias ExRatatui.Layout.Rect + alias ExRatatui.Style + alias ExRatatui.Text.Span + alias ExRatatui.Widgets.{Block, 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" 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 + setup do + [widgets: Counter.render(%{count: 7}, frame())] + end + + test "produces the shared chrome plus a centered count and a single hint row", %{ + widgets: widgets + } do + assert length(widgets) == 3 + + [{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: 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 "key chips render in reverse-video and action labels are plain", %{widgets: widgets} do + [_, _, {%Paragraph{text: spans}, _}] = widgets + + reversed = %Style{modifiers: [:reversed]} + + # 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) + + for span_index <- [1, 3, 5, 7] do + assert %Span{style: %Style{modifiers: []}} = Enum.at(spans, span_index) + end + end + + test "every rect fits within the frame" do + 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 frame, do: %Rect{x: 0, y: 0, width: 66, height: 37} + defp key(code), do: %Key{code: code, kind: "press", modifiers: []} +end diff --git a/test/name_badge/screen/ex_ratatui/goathi_test.exs b/test/name_badge/screen/ex_ratatui/goathi_test.exs new file mode 100644 index 0000000..ae3448e --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/goathi_test.exs @@ -0,0 +1,228 @@ +defmodule NameBadge.Screen.ExRatatui.GoathiTest 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.Points + alias NameBadge.Screen.ExRatatui.Goathi + alias NameBadge.Screen.ExRatatui.Goathi.Art + + describe "init/1" do + test "starts at tick 0 and unpaused" do + 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}} = + Goathi.update({:event, key("up")}, %{tick: 7, paused?: false}) + + assert {:noreply, %{paused?: false}} = + 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}} = + Goathi.update({:event, key("home")}, %{tick: 99, paused?: false}) + + assert {:noreply, %{tick: 0, 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} = 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}} = + Goathi.update({:info, :tick}, %{tick: 0, paused?: false}) + + assert {:noreply, %{tick: 43}} = + Goathi.update({:info, :tick}, %{tick: 42, paused?: false}) + end + + test "tick is a no-op when paused" do + assert {:noreply, %{tick: 42}} = + Goathi.update({:info, :tick}, %{tick: 42, paused?: true}) + end + + test "ignores unrelated info messages" do + state = %{tick: 1, paused?: false} + assert {:noreply, ^state} = Goathi.update({:info, :unrelated}, state) + end + end + + describe "subscriptions/1" do + test "registers a hardware-friendly tick subscription with a stable id" do + assert [ + %Subscription{ + id: :goat_tick, + kind: :interval, + interval_ms: interval, + message: :tick + } + ] = 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. + assert interval >= 700 + 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 "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} = Art.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}]} = Art.ascii_to_points("#", 7.5, 3.0) + assert {x, y} == {7.5, 3.0} + end + end + + describe "render/2" do + setup do + [widgets: Goathi.render(%{tick: 0, 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: " ex_ratatui - goathi ", borders: [:all]} + } = canvas + + assert %Paragraph{text: spans} = hint + assert is_list(spans) + + # Canvas owns the screen above the hint row. + assert canvas_rect.height == frame().height - 2 + assert hint_rect.y == frame().height - 1 + end + + test "the face renders as a Points shape with many ink cells", %{widgets: widgets} do + [{%Canvas{shapes: shapes}, _}, _] = widgets + + assert length(shapes) >= 2 + + # 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(face) > 50 + end + + test "frames alternate between successive ticks (animation alive)" do + [{%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 |> all_coords() |> MapSet.new() + coords_b = shapes_b |> all_coords() |> MapSet.new() + + refute MapSet.equal?(coords_a, coords_b) + end + + 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()) + + # 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 + [_, {%Paragraph{text: spans_running}, _}] = + Goathi.render(%{tick: 0, paused?: false}, frame()) + + [_, {%Paragraph{text: spans_paused}, _}] = + 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" + 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 = Goathi.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 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 new file mode 100644 index 0000000..2d5e6fa --- /dev/null +++ b/test/name_badge/screen/ex_ratatui/stats_test.exs @@ -0,0 +1,332 @@ +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 + 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 + + 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 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 + } + + {:noreply, after_reset} = Stats.update({:event, key("home")}, state) + + 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 + 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, 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 ":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 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 + paused = %{state | paused?: true} + assert {:noreply, ^paused} = Stats.update({:info, :refresh}, paused) + end + + 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 ":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 -> + {:noreply, next} = Stats.update({:info, {:sample_taken, sample}}, acc) + next + end) + + 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 + 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 + test "registers a hardware-friendly refresh subscription with a stable id" do + assert [ + %Subscription{ + id: :stats_refresh, + kind: :interval, + 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 bar chart, gauges, sparklines, and the top-N panel. + assert interval >= 1_000 + end + end + + describe "render/2" do + setup do + {:ok, state} = Stats.init([]) + [state: state, widgets: Stats.render(state, frame())] + end + + test "wraps each section in its own titled block on top of the outer chrome", + %{widgets: widgets} do + titles = block_titles(widgets) + + # Outer Frame 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) == 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())) + + [_, {%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 + + 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 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..1ea800b --- /dev/null +++ b/test/name_badge/screen/ex_ratatui_test.exs @@ -0,0 +1,292 @@ +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 + + 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 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 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) + + assert is_pid(screen.assigns.server) + assert Process.alive?(screen.assigns.server) + assert %ExRatatui.CellSession{} = screen.assigns.session + + # 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 + + 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() + + 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 "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{ + 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 + + defp stop_server(screen) do + if pid = screen.assigns[:server], do: GenServer.stop(pid, :normal, 1_000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end +end