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:
parent
d7b393b7c2
commit
676d9879fa
3 changed files with 215 additions and 45 deletions
|
|
@ -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) ----
|
||||||
.draw(d)?;
|
let name_style = MonoTextStyle::new(&FONT_10X20, TXT);
|
||||||
// Red 8px border on all four edges.
|
Text::new(s.name, Point::new(M, 22), name_style).draw(d)?;
|
||||||
Rectangle::new(Point::zero(), bb.size)
|
|
||||||
.into_styled(
|
let bpm_style = MonoTextStyle::new(&FONT_9X18_BOLD, CYAN);
|
||||||
PrimitiveStyleBuilder::new()
|
let mut nb = [0u8; 12];
|
||||||
.stroke_color(Rgb565::RED)
|
let bpm_str = fmt_u32(s.bpm.max(0) as u32, &mut nb);
|
||||||
.stroke_width(8)
|
Text::with_alignment(bpm_str, Point::new(w - M, 18), bpm_style, Alignment::Right).draw(d)?;
|
||||||
.build(),
|
let lbl = MonoTextStyle::new(&FONT_6X10, MUTE);
|
||||||
)
|
Text::with_alignment("BPM", Point::new(w - M, 34), lbl, Alignment::Right).draw(d)?;
|
||||||
.draw(d)?;
|
|
||||||
// Distinct corner markers: TL green / TR yellow / BL cyan / BR magenta.
|
// ---- transport line ----
|
||||||
let ms = Size::new(m as u32, m as u32);
|
let ty = 46;
|
||||||
for (x, y, c) in [
|
if s.playing {
|
||||||
(0, 0, Rgb565::GREEN),
|
Triangle::new(Point::new(M, ty - 8), Point::new(M, ty + 4), Point::new(M + 11, ty - 2))
|
||||||
(w - m, 0, Rgb565::YELLOW),
|
.into_styled(PrimitiveStyle::with_fill(GREEN))
|
||||||
(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)?;
|
.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>(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)?;
|
||||||
}
|
}
|
||||||
// Labels.
|
|
||||||
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)?;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue