From 676d9879fae9a57c28a5dc98d94b9d2f6e2406df Mon Sep 17 00:00:00 2001 From: Me Here Date: Sun, 31 May 2026 23:38:48 -0500 Subject: [PATCH] pm-ui: first real metronome screen (header/BPM/transport + polymeter lane grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- rust/pm-ui/src/lib.rs | 213 ++++++++++++++++++++++++++++++++++------- rust/uisim/Cargo.toml | 2 + rust/uisim/src/main.rs | 45 ++++++--- 3 files changed, 215 insertions(+), 45 deletions(-) diff --git a/rust/pm-ui/src/lib.rs b/rust/pm-ui/src/lib.rs index 5b3e2cf..611737a 100644 --- a/rust/pm-ui/src/lib.rs +++ b/rust/pm-ui/src/lib.rs @@ -1,56 +1,203 @@ //! 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 -//! SAME code onto a framebuffer and exports a PNG. So UI/layout can be developed and reviewed -//! without the device — only true panel/controller quirks need the bench. +//! SAME code onto a framebuffer and exports a PNG. So UI/layout is developed and reviewed +//! 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] use embedded_graphics::{ - mono_font::{ascii::FONT_10X20, MonoTextStyle}, + mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle}, pixelcolor::Rgb565, prelude::*, - primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle}, - text::Text, + primitives::{Line, PrimitiveStyle, PrimitiveStyleBuilder, Rectangle, RoundedRectangle, Triangle}, + text::{Alignment, Text}, }; -/// Current bring-up diagnostic: solid fill + 4-edge border + distinct corner markers + labels. -/// (This is where the real metronome UI will grow.) -pub fn draw_ui(d: &mut D) -> Result<(), D::Error> +// ---- palette (0xRRGGBB → Rgb565) ---- +const fn rgb(hex: u32) -> Rgb565 { + 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: &mut D, s: &Screen) -> Result<(), D::Error> where D: DrawTarget, { let bb = d.bounding_box(); let w = bb.size.width as i32; let h = bb.size.height as i32; - let m: i32 = 36; + const M: i32 = 12; - // Full-screen background. - Rectangle::new(Point::zero(), bb.size) - .into_styled(PrimitiveStyle::with_fill(Rgb565::new(0, 0, 31))) - .draw(d)?; - // Red 8px border on all four edges. - Rectangle::new(Point::zero(), bb.size) - .into_styled( - PrimitiveStyleBuilder::new() - .stroke_color(Rgb565::RED) - .stroke_width(8) - .build(), - ) - .draw(d)?; - // Distinct corner markers: TL green / TR yellow / BL cyan / BR magenta. - 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)) + d.clear(BG)?; + + // ---- 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)?; + } else { + Rectangle::new(Point::new(M, ty - 8), Size::new(11, 12)).into_styled(PrimitiveStyle::with_fill(MUTE)).draw(d)?; + } + let st = MonoTextStyle::new(&FONT_6X10, if s.playing { GREEN } else { MUTE }); + Text::new(if s.playing { "PLAYING" } else { "STOPPED" }, Point::new(M + 20, ty), st).draw(d)?; + + // 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)?; + // playhead highlight on the active step while playing + if s.playing && sidx as i32 == ph_step { + RoundedRectangle::with_equal_corners( + Rectangle::new(Point::new(cx, cy), Size::new(cw, ch as u32)), + Size::new(3, 3), + ) + .into_styled(PrimitiveStyleBuilder::new().stroke_color(TXT).stroke_width(2).build()) + .draw(d)?; + } + } + // 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: &mut D) -> Result<(), D::Error> +where + D: DrawTarget, +{ + 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)?; } - // Labels. let label = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE); Text::new("TL", Point::new(44, 28), label).draw(d)?; Text::new("PMK", Point::new(w / 2 - 30, h / 2), label).draw(d)?; diff --git a/rust/uisim/Cargo.toml b/rust/uisim/Cargo.toml index 8d3bf9b..ff51e9b 100644 --- a/rust/uisim/Cargo.toml +++ b/rust/uisim/Cargo.toml @@ -3,9 +3,11 @@ name = "uisim" version = "0.1.0" edition = "2021" description = "Host renderer for pm-ui: draws the firmware UI to a framebuffer and exports a PNG (no hardware)." +default-run = "uisim" [dependencies] pm-ui = { path = "../pm-ui" } +track-format = { path = "../track-format" } embedded-graphics = "0.8" image = { version = "0.25", default-features = false, features = ["png"] } # initdump binary: capture mipidsi's ST7796 init command sequence on the host diff --git a/rust/uisim/src/main.rs b/rust/uisim/src/main.rs index ddafd6e..2574298 100644 --- a/rust/uisim/src/main.rs +++ b/rust/uisim/src/main.rs @@ -1,26 +1,23 @@ -//! Render the shared pm-ui onto a 320×480 framebuffer and save it as a PNG — no device, no SDL. -//! `cargo run` (host) → pm-kit-ui.png. Lets the UI be developed/reviewed off the bench. +//! Render the metronome UI (pm-ui) for a real parsed track onto a 320×480 framebuffer and save a +//! PNG — no device. `cargo run` [program-string]. Lets the UI be developed/reviewed off the bench. use embedded_graphics::{pixelcolor::Rgb565, prelude::*}; +use pm_ui::{LaneView, Screen}; const W: u32 = 320; const H: u32 = 480; -/// A trivial framebuffer DrawTarget. struct Fb { px: Vec, } - impl Fb { fn new() -> Self { Fb { px: vec![Rgb565::BLACK; (W * H) as usize] } } } - impl DrawTarget for Fb { type Color = Rgb565; type Error = core::convert::Infallible; - fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> where I: IntoIterator>, @@ -33,7 +30,6 @@ impl DrawTarget for Fb { Ok(()) } } - impl OriginDimensions for Fb { fn size(&self) -> Size { Size::new(W, H) @@ -41,18 +37,43 @@ impl OriginDimensions for Fb { } fn main() { + let args: Vec = 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 = track + .lanes + .iter() + .map(|l| LaneView { + name: &l.sound, + levels: &l.levels, + beats: l.groups.iter().sum::().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(); - 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 c = fb.px[(y * W + x) as usize]; - // Rgb565 channels → 8-bit let r = (c.r() << 3) | (c.r() >> 2); let g = (c.g() << 2) | (c.g() >> 4); let b = (c.b() << 3) | (c.b() >> 2); image::Rgb([r, g, b]) }); - let out = std::env::args().nth(1).unwrap_or_else(|| "pm-kit-ui.png".into()); - img.save(&out).unwrap(); - println!("wrote {out} ({W}x{H})"); + let out = "pm-kit-ui.png"; + img.save(out).unwrap(); + println!("wrote {out} ({W}x{H}) for: {prog}"); }