pm-ui: first real metronome screen (header/BPM/transport + polymeter lane grid)

draw_metronome() renders the screen for any parsed track: track name + big BPM,
play/stop transport, and the polymeter lane grid — per-lane beat cells coloured by
level (accent amber / normal cyan / ghost purple / rest dark), playhead highlight,
beat gridlines, poly (~) marker. Pure no_std view over borrowed data (LaneView/
Screen) so the firmware build stays allocator-free.

uisim now parses a real track (track-format) and renders draw_metronome to PNG —
iterate the UI on the host, no bench. Firmware still draws the bring-up diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 23:38:48 -05:00
parent d7b393b7c2
commit 676d9879fa
3 changed files with 215 additions and 45 deletions

View file

@ -1,56 +1,203 @@
//! Shared PM_K-1 UI rendering — `no_std`, generic over any `embedded-graphics` `DrawTarget`. //! Shared PM_K-1 UI rendering — `no_std`, generic over any `embedded-graphics` `DrawTarget`.
//! //!
//! The firmware draws this onto the real ST7796; the host simulator (`rust/uisim`) draws the //! The firmware draws this onto the real ST7796; the host simulator (`rust/uisim`) draws the
//! SAME code onto a framebuffer and exports a PNG. So UI/layout can be developed and reviewed //! SAME code onto a framebuffer and exports a PNG. So UI/layout is developed and reviewed
//! without the device — only true panel/controller quirks need the bench. //! without the device. `pm-ui` is a pure *view* over borrowed data (no alloc) — the data source
//! (a parsed track + app state) lives elsewhere.
#![no_std] #![no_std]
use embedded_graphics::{ use embedded_graphics::{
mono_font::{ascii::FONT_10X20, MonoTextStyle}, mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle},
pixelcolor::Rgb565, pixelcolor::Rgb565,
prelude::*, prelude::*,
primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle}, primitives::{Line, PrimitiveStyle, PrimitiveStyleBuilder, Rectangle, RoundedRectangle, Triangle},
text::Text, text::{Alignment, Text},
}; };
/// Current bring-up diagnostic: solid fill + 4-edge border + distinct corner markers + labels. // ---- palette (0xRRGGBB → Rgb565) ----
/// (This is where the real metronome UI will grow.) const fn rgb(hex: u32) -> Rgb565 {
pub fn draw_ui<D>(d: &mut D) -> Result<(), D::Error> Rgb565::new(((hex >> 19) & 0x1F) as u8, ((hex >> 10) & 0x3F) as u8, ((hex >> 3) & 0x1F) as u8)
}
const BG: Rgb565 = rgb(0x06090E);
const PANEL: Rgb565 = rgb(0x141A24);
const TXT: Rgb565 = rgb(0xC7D0DB);
const MUTE: Rgb565 = rgb(0x55606E);
const CYAN: Rgb565 = rgb(0x0AB3F7);
const AMBER: Rgb565 = rgb(0xFF9B2E);
const GREEN: Rgb565 = rgb(0x2FE07A);
const GHOST: Rgb565 = rgb(0x7A5AD0);
const CELL_REST: Rgb565 = rgb(0x1A2330);
const GRID: Rgb565 = rgb(0x26303E);
/// One meter lane to draw.
pub struct LaneView<'a> {
pub name: &'a str,
/// per-step dynamics: 0 rest, 1 normal, 2 accent, 3 ghost
pub levels: &'a [u8],
/// beats per bar (for the group/beat gridlines); 0 = none
pub beats: u8,
pub poly: bool,
pub muted: bool,
}
/// The full metronome screen state.
pub struct Screen<'a> {
pub name: &'a str,
pub bpm: i64,
pub playing: bool,
/// progress through the master bar, 0.0..1.0 (drives the playhead column)
pub phase: f32,
pub lanes: &'a [LaneView<'a>],
}
fn level_color(l: u8) -> Rgb565 {
match l {
2 => AMBER,
3 => GHOST,
1 => CYAN,
_ => CELL_REST,
}
}
/// Format a small unsigned int into a stack buffer (no alloc).
fn fmt_u32(mut n: u32, buf: &mut [u8; 12]) -> &str {
if n == 0 {
buf[11] = b'0';
return core::str::from_utf8(&buf[11..]).unwrap();
}
let mut i = buf.len();
while n > 0 {
i -= 1;
buf[i] = b'0' + (n % 10) as u8;
n /= 10;
}
core::str::from_utf8(&buf[i..]).unwrap()
}
/// Draw the metronome screen. Designed for 320×480 portrait but reads the size from the target.
pub fn draw_metronome<D>(d: &mut D, s: &Screen) -> Result<(), D::Error>
where where
D: DrawTarget<Color = Rgb565>, D: DrawTarget<Color = Rgb565>,
{ {
let bb = d.bounding_box(); let bb = d.bounding_box();
let w = bb.size.width as i32; let w = bb.size.width as i32;
let h = bb.size.height as i32; let h = bb.size.height as i32;
let m: i32 = 36; const M: i32 = 12;
// Full-screen background. d.clear(BG)?;
Rectangle::new(Point::zero(), bb.size)
.into_styled(PrimitiveStyle::with_fill(Rgb565::new(0, 0, 31))) // ---- header: track name (left) + big BPM (right) ----
let name_style = MonoTextStyle::new(&FONT_10X20, TXT);
Text::new(s.name, Point::new(M, 22), name_style).draw(d)?;
let bpm_style = MonoTextStyle::new(&FONT_9X18_BOLD, CYAN);
let mut nb = [0u8; 12];
let bpm_str = fmt_u32(s.bpm.max(0) as u32, &mut nb);
Text::with_alignment(bpm_str, Point::new(w - M, 18), bpm_style, Alignment::Right).draw(d)?;
let lbl = MonoTextStyle::new(&FONT_6X10, MUTE);
Text::with_alignment("BPM", Point::new(w - M, 34), lbl, Alignment::Right).draw(d)?;
// ---- transport line ----
let ty = 46;
if s.playing {
Triangle::new(Point::new(M, ty - 8), Point::new(M, ty + 4), Point::new(M + 11, ty - 2))
.into_styled(PrimitiveStyle::with_fill(GREEN))
.draw(d)?; .draw(d)?;
// Red 8px border on all four edges. } else {
Rectangle::new(Point::zero(), bb.size) Rectangle::new(Point::new(M, ty - 8), Size::new(11, 12)).into_styled(PrimitiveStyle::with_fill(MUTE)).draw(d)?;
.into_styled( }
PrimitiveStyleBuilder::new() let st = MonoTextStyle::new(&FONT_6X10, if s.playing { GREEN } else { MUTE });
.stroke_color(Rgb565::RED) Text::new(if s.playing { "PLAYING" } else { "STOPPED" }, Point::new(M + 20, ty), st).draw(d)?;
.stroke_width(8)
.build(), // divider
Line::new(Point::new(M, 58), Point::new(w - M, 58)).into_styled(PrimitiveStyle::with_stroke(PANEL, 2)).draw(d)?;
// ---- lane grid ----
let grid_top = 70;
let footer = 16;
let n = s.lanes.len().max(1) as i32;
let row_h = (((h - grid_top - footer) / n).min(54)).max(20);
let label_w = 58;
let gx0 = M + label_w;
let gx1 = w - M;
let gw = (gx1 - gx0).max(1);
let name_sty_on = MonoTextStyle::new(&FONT_6X10, TXT);
let name_sty_off = MonoTextStyle::new(&FONT_6X10, MUTE);
for (i, lane) in s.lanes.iter().enumerate() {
let ry = grid_top + i as i32 * row_h;
let cy = ry + 4;
let ch = row_h - 8;
// lane name (left)
let nstyle = if lane.muted { name_sty_off } else { name_sty_on };
Text::new(lane.name, Point::new(M, ry + row_h / 2 + 4), nstyle).draw(d)?;
if lane.poly {
Text::new("~", Point::new(M + label_w - 10, ry + row_h / 2 + 4), MonoTextStyle::new(&FONT_6X10, GHOST)).draw(d)?;
}
// cells
let steps = lane.levels.len().max(1) as i32;
let cell_w = gw / steps;
let ph_step = (s.phase * steps as f32) as i32;
for (sidx, &lvl) in lane.levels.iter().enumerate() {
let cx = gx0 + sidx as i32 * cell_w;
let cw = (cell_w - 2).max(1) as u32;
let col = if lane.muted { CELL_REST } else { level_color(lvl) };
RoundedRectangle::with_equal_corners(
Rectangle::new(Point::new(cx, cy), Size::new(cw, ch as u32)),
Size::new(3, 3),
) )
.into_styled(PrimitiveStyle::with_fill(col))
.draw(d)?; .draw(d)?;
// Distinct corner markers: TL green / TR yellow / BL cyan / BR magenta. // playhead highlight on the active step while playing
let ms = Size::new(m as u32, m as u32); if s.playing && sidx as i32 == ph_step {
for (x, y, c) in [ RoundedRectangle::with_equal_corners(
(0, 0, Rgb565::GREEN), Rectangle::new(Point::new(cx, cy), Size::new(cw, ch as u32)),
(w - m, 0, Rgb565::YELLOW), Size::new(3, 3),
(0, h - m, Rgb565::CYAN), )
(w - m, h - m, Rgb565::MAGENTA), .into_styled(PrimitiveStyleBuilder::new().stroke_color(TXT).stroke_width(2).build())
] {
Rectangle::new(Point::new(x, y), ms)
.into_styled(PrimitiveStyle::with_fill(c))
.draw(d)?; .draw(d)?;
} }
// Labels. }
// faint beat gridlines across this lane
if lane.beats > 1 {
let sub = steps / lane.beats as i32;
if sub >= 1 {
for b in 1..lane.beats as i32 {
let lx = gx0 + b * sub * cell_w;
Line::new(Point::new(lx, cy), Point::new(lx, cy + ch))
.into_styled(PrimitiveStyle::with_stroke(GRID, 1))
.draw(d)?;
}
}
}
}
// ---- footer ----
let fstyle = MonoTextStyle::new(&FONT_6X10, MUTE);
Text::new("PM_K-1", Point::new(M, h - 4), fstyle).draw(d)?;
Ok(())
}
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).
pub fn draw_ui<D>(d: &mut D) -> Result<(), D::Error>
where
D: DrawTarget<Color = Rgb565>,
{
let bb = d.bounding_box();
let (w, h) = (bb.size.width as i32, bb.size.height as i32);
let m: i32 = 36;
Rectangle::new(Point::zero(), bb.size).into_styled(PrimitiveStyle::with_fill(Rgb565::new(0, 0, 31))).draw(d)?;
Rectangle::new(Point::zero(), bb.size)
.into_styled(PrimitiveStyleBuilder::new().stroke_color(Rgb565::RED).stroke_width(8).build())
.draw(d)?;
let ms = Size::new(m as u32, m as u32);
for (x, y, c) in [(0, 0, Rgb565::GREEN), (w - m, 0, Rgb565::YELLOW), (0, h - m, Rgb565::CYAN), (w - m, h - m, Rgb565::MAGENTA)] {
Rectangle::new(Point::new(x, y), ms).into_styled(PrimitiveStyle::with_fill(c)).draw(d)?;
}
let label = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE); let label = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
Text::new("TL", Point::new(44, 28), label).draw(d)?; Text::new("TL", Point::new(44, 28), label).draw(d)?;
Text::new("PMK", Point::new(w / 2 - 30, h / 2), label).draw(d)?; Text::new("PMK", Point::new(w / 2 - 30, h / 2), label).draw(d)?;

View file

@ -3,9 +3,11 @@ name = "uisim"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Host renderer for pm-ui: draws the firmware UI to a framebuffer and exports a PNG (no hardware)." description = "Host renderer for pm-ui: draws the firmware UI to a framebuffer and exports a PNG (no hardware)."
default-run = "uisim"
[dependencies] [dependencies]
pm-ui = { path = "../pm-ui" } pm-ui = { path = "../pm-ui" }
track-format = { path = "../track-format" }
embedded-graphics = "0.8" embedded-graphics = "0.8"
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
# initdump binary: capture mipidsi's ST7796 init command sequence on the host # initdump binary: capture mipidsi's ST7796 init command sequence on the host

View file

@ -1,26 +1,23 @@
//! Render the shared pm-ui onto a 320×480 framebuffer and save it as a PNG — no device, no SDL. //! Render the metronome UI (pm-ui) for a real parsed track onto a 320×480 framebuffer and save a
//! `cargo run` (host) → pm-kit-ui.png. Lets the UI be developed/reviewed off the bench. //! PNG — no device. `cargo run` [program-string]. Lets the UI be developed/reviewed off the bench.
use embedded_graphics::{pixelcolor::Rgb565, prelude::*}; use embedded_graphics::{pixelcolor::Rgb565, prelude::*};
use pm_ui::{LaneView, Screen};
const W: u32 = 320; const W: u32 = 320;
const H: u32 = 480; const H: u32 = 480;
/// A trivial framebuffer DrawTarget.
struct Fb { struct Fb {
px: Vec<Rgb565>, px: Vec<Rgb565>,
} }
impl Fb { impl Fb {
fn new() -> Self { fn new() -> Self {
Fb { px: vec![Rgb565::BLACK; (W * H) as usize] } Fb { px: vec![Rgb565::BLACK; (W * H) as usize] }
} }
} }
impl DrawTarget for Fb { impl DrawTarget for Fb {
type Color = Rgb565; type Color = Rgb565;
type Error = core::convert::Infallible; type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error> fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where where
I: IntoIterator<Item = Pixel<Self::Color>>, I: IntoIterator<Item = Pixel<Self::Color>>,
@ -33,7 +30,6 @@ impl DrawTarget for Fb {
Ok(()) Ok(())
} }
} }
impl OriginDimensions for Fb { impl OriginDimensions for Fb {
fn size(&self) -> Size { fn size(&self) -> Size {
Size::new(W, H) Size::new(W, H)
@ -41,18 +37,43 @@ impl OriginDimensions for Fb {
} }
fn main() { fn main() {
let args: Vec<String> = std::env::args().collect();
let prog = args
.get(1)
.cloned()
.unwrap_or_else(|| "t128;kick:4=Xxxx;snare:4=.x.x;hatClosed:4/2;ride:4/2s=X.x.x.x.;cowbell:3~".into());
let track = track_format::parse(&prog);
let lanes: Vec<LaneView> = track
.lanes
.iter()
.map(|l| LaneView {
name: &l.sound,
levels: &l.levels,
beats: l.groups.iter().sum::<u32>().min(255) as u8,
poly: l.poly,
muted: l.mute,
})
.collect();
let screen = Screen {
name: "Four-on-the-floor",
bpm: track.bpm,
playing: true,
phase: 0.30,
lanes: &lanes,
};
let mut fb = Fb::new(); let mut fb = Fb::new();
pm_ui::draw_ui(&mut fb).unwrap(); pm_ui::draw_metronome(&mut fb, &screen).unwrap();
let img = image::RgbImage::from_fn(W, H, |x, y| { let img = image::RgbImage::from_fn(W, H, |x, y| {
let c = fb.px[(y * W + x) as usize]; let c = fb.px[(y * W + x) as usize];
// Rgb565 channels → 8-bit
let r = (c.r() << 3) | (c.r() >> 2); let r = (c.r() << 3) | (c.r() >> 2);
let g = (c.g() << 2) | (c.g() >> 4); let g = (c.g() << 2) | (c.g() >> 4);
let b = (c.b() << 3) | (c.b() >> 2); let b = (c.b() << 3) | (c.b() >> 2);
image::Rgb([r, g, b]) image::Rgb([r, g, b])
}); });
let out = std::env::args().nth(1).unwrap_or_else(|| "pm-kit-ui.png".into()); let out = "pm-kit-ui.png";
img.save(&out).unwrap(); img.save(out).unwrap();
println!("wrote {out} ({W}x{H})"); println!("wrote {out} ({W}x{H}) for: {prog}");
} }