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`.
|
||||
//!
|
||||
//! 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>(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>(d: &mut D, s: &Screen) -> Result<(), D::Error>
|
||||
where
|
||||
D: DrawTarget<Color = Rgb565>,
|
||||
{
|
||||
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)))
|
||||
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)?;
|
||||
// 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(),
|
||||
} 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)?;
|
||||
// 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))
|
||||
// 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)?;
|
||||
}
|
||||
// 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);
|
||||
Text::new("TL", Point::new(44, 28), 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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Rgb565>,
|
||||
}
|
||||
|
||||
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<I>(&mut self, pixels: I) -> Result<(), Self::Error>
|
||||
where
|
||||
I: IntoIterator<Item = Pixel<Self::Color>>,
|
||||
|
|
@ -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<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();
|
||||
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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue