Smooth sparkline, portrait media card, and dynamic grid layout
- Replace bar-chart sparkline with catmull-rom interpolated area chart using per-pixel columns and a bright top edge line - Rework media card to portrait layout: large centered album art circle with glow, vertically stacked track info below, centered text - Implement cell-occupation grid to support 2-row tall media cards without breaking the 4-column layout for other cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e29050dc32
commit
c258a0e29a
1 file changed
+145
-56
+145
-56
@@ -1676,18 +1676,43 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card grid ────────────────────────────────────────────────
|
||||
// ── Card grid (supports 2-row tall media cards) ────────────
|
||||
let cols = 4usize;
|
||||
let card_gap = 16.;
|
||||
let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32;
|
||||
let card_h = 160.;
|
||||
let max_rows = (room.cards.len() + cols - 1) / cols + 2;
|
||||
let mut occupied = vec![false; cols * max_rows];
|
||||
|
||||
for (index, card) in room.cards.iter().enumerate() {
|
||||
let col = index % cols;
|
||||
let row = index / cols;
|
||||
let cx = 84. + col as f32 * (card_w + card_gap);
|
||||
let cy = y + row as f32 * (card_h + card_gap);
|
||||
let rect = Rect::new(cx, cy, card_w, card_h);
|
||||
let tall = matches!(card.card_type, HaCardType::Media);
|
||||
let row_span = if tall { 2 } else { 1 };
|
||||
|
||||
// Find next available cell that fits
|
||||
let mut placed_col = 0;
|
||||
let mut placed_row = 0;
|
||||
'search: for r in 0..max_rows {
|
||||
for c in 0..cols {
|
||||
let fits = (0..row_span).all(|dr| {
|
||||
let idx = (r + dr) * cols + c;
|
||||
idx < occupied.len() && !occupied[idx]
|
||||
});
|
||||
if fits {
|
||||
placed_col = c;
|
||||
placed_row = r;
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
for dr in 0..row_span {
|
||||
let idx = (placed_row + dr) * cols + placed_col;
|
||||
if idx < occupied.len() { occupied[idx] = true; }
|
||||
}
|
||||
|
||||
let cx = 84. + placed_col as f32 * (card_w + card_gap);
|
||||
let cy = y + placed_row as f32 * (card_h + card_gap);
|
||||
let rh = if tall { card_h * 2. + card_gap } else { card_h };
|
||||
let rect = Rect::new(cx, cy, card_w, rh);
|
||||
let focused = state.focus == Target::HaCard(state.ha.room, index);
|
||||
|
||||
let entity = state.ha.entities.get(&card.entity_id);
|
||||
@@ -2094,22 +2119,74 @@ fn ha_climate_card(
|
||||
}
|
||||
|
||||
fn ha_sparkline(out: &mut Scene, rect: Rect, points: &[(f64, f64)], color: Color) {
|
||||
if points.is_empty() { return; }
|
||||
if points.len() < 2 { return; }
|
||||
let min_val = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
|
||||
let max_val = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
|
||||
let range = (max_val - min_val).max(1.);
|
||||
let step = rect.w / (points.len() as f32 - 1.).max(1.);
|
||||
for i in 0..points.len() {
|
||||
let x = rect.x + i as f32 * step;
|
||||
let norm = ((points[i].1 - min_val) / range) as f32;
|
||||
let bar_h = (norm * rect.h).max(2.);
|
||||
let y = rect.y + rect.h - bar_h;
|
||||
let range = (max_val - min_val).max(0.001);
|
||||
|
||||
let n = points.len();
|
||||
let vals: Vec<f32> = points.iter()
|
||||
.map(|p| ((p.1 - min_val) / range) as f32)
|
||||
.collect();
|
||||
|
||||
let cols = (rect.w as usize).max(4);
|
||||
let col_w = rect.w / cols as f32;
|
||||
|
||||
for col in 0..cols {
|
||||
let t = col as f32 / (cols - 1).max(1) as f32;
|
||||
let seg = t * (n - 1) as f32;
|
||||
let i = (seg as usize).min(n - 2);
|
||||
let frac = seg - i as f32;
|
||||
|
||||
let p0 = vals[if i > 0 { i - 1 } else { 0 }];
|
||||
let p1 = vals[i];
|
||||
let p2 = vals[(i + 1).min(n - 1)];
|
||||
let p3 = vals[(i + 2).min(n - 1)];
|
||||
|
||||
let norm = catmull_rom(p0, p1, p2, p3, frac).clamp(0., 1.);
|
||||
let h = (norm * rect.h).max(1.);
|
||||
let x = rect.x + col as f32 * col_w;
|
||||
let y = rect.y + rect.h - h;
|
||||
|
||||
out.shape(
|
||||
Rect::new(x, y, step.max(2.), bar_h),
|
||||
Rect::new(x, y, col_w + 0.5, h),
|
||||
0.,
|
||||
[color[0], color[1], color[2], 0.35],
|
||||
[color[0], color[1], color[2], 0.25],
|
||||
);
|
||||
}
|
||||
|
||||
// Bright top edge
|
||||
for col in 0..cols {
|
||||
let t = col as f32 / (cols - 1).max(1) as f32;
|
||||
let seg = t * (n - 1) as f32;
|
||||
let i = (seg as usize).min(n - 2);
|
||||
let frac = seg - i as f32;
|
||||
|
||||
let p0 = vals[if i > 0 { i - 1 } else { 0 }];
|
||||
let p1 = vals[i];
|
||||
let p2 = vals[(i + 1).min(n - 1)];
|
||||
let p3 = vals[(i + 2).min(n - 1)];
|
||||
|
||||
let norm = catmull_rom(p0, p1, p2, p3, frac).clamp(0., 1.);
|
||||
let h = (norm * rect.h).max(1.);
|
||||
let x = rect.x + col as f32 * col_w;
|
||||
let y = rect.y + rect.h - h;
|
||||
|
||||
out.shape(
|
||||
Rect::new(x, y, col_w + 0.5, 2.),
|
||||
0.,
|
||||
[color[0], color[1], color[2], 0.7],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn catmull_rom(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
0.5 * ((2. * p1)
|
||||
+ (-p0 + p2) * t
|
||||
+ (2. * p0 - 5. * p1 + 4. * p2 - p3) * t2
|
||||
+ (-p0 + 3. * p1 - 3. * p2 + p3) * t3)
|
||||
}
|
||||
|
||||
// ── Action card (scenes, scripts) ────────────────────────────────
|
||||
@@ -2252,7 +2329,6 @@ fn ha_media_card(
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let accent = ha_state_color(entity);
|
||||
let is_playing = entity.is_some_and(|e| e.state == "playing");
|
||||
let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused");
|
||||
|
||||
// Volume fill background
|
||||
@@ -2265,10 +2341,23 @@ fn ha_media_card(
|
||||
ha_card_fill(out, rect, volume, fill_color);
|
||||
}
|
||||
|
||||
// Album art circle (right side)
|
||||
let art_size = 96.;
|
||||
let art_x = rect.x + rect.w - art_size - 20.;
|
||||
let art_y = rect.y + (rect.h - art_size) / 2.;
|
||||
// Portrait layout: album art circle centered at top
|
||||
let art_size = 120.;
|
||||
let art_x = rect.x + (rect.w - art_size) / 2.;
|
||||
let art_y = rect.y + 24.;
|
||||
|
||||
// Glow behind album art
|
||||
if is_active {
|
||||
let glow_size = art_size * 1.8;
|
||||
let glow_x = art_x + (art_size - glow_size) / 2.;
|
||||
let glow_y = art_y + (art_size - glow_size) / 2.;
|
||||
out.shape(
|
||||
Rect::new(glow_x, glow_y, glow_size, glow_size),
|
||||
glow_size / 2.,
|
||||
[accent[0], accent[1], accent[2], 0.12],
|
||||
);
|
||||
}
|
||||
|
||||
out.shape(
|
||||
Rect::new(art_x, art_y, art_size, art_size),
|
||||
art_size / 2.,
|
||||
@@ -2277,44 +2366,29 @@ fn ha_media_card(
|
||||
out.icon(
|
||||
assets,
|
||||
"spotify",
|
||||
Rect::new(art_x + 24., art_y + 24., 48., 48.),
|
||||
Rect::new(art_x + 30., art_y + 30., 60., 60.),
|
||||
if is_active { accent } else { c(0xbbbbbb, 0.4) },
|
||||
);
|
||||
|
||||
// Media source / state label
|
||||
let media_source = entity
|
||||
.and_then(|e| e.attributes.get("media_content_type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Media");
|
||||
let state_str = entity.map_or("off", |e| e.state.as_str());
|
||||
let status_label = match state_str {
|
||||
"playing" => format!("{} · Playing", media_source),
|
||||
"paused" => format!("{} · Paused", media_source),
|
||||
"idle" => "Idle".into(),
|
||||
_ => "Off".into(),
|
||||
};
|
||||
out.text(
|
||||
&status_label,
|
||||
Rect::new(rect.x + 16., rect.y + 16., rect.w - art_size - 56., 14.),
|
||||
text(12., 400, 0.03),
|
||||
if is_active { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
// Text area below album art
|
||||
let text_y = art_y + art_size + 16.;
|
||||
let text_x = rect.x + 16.;
|
||||
let text_w = rect.w - 32.;
|
||||
|
||||
// Song title
|
||||
// Song title (centered)
|
||||
let title = entity
|
||||
.and_then(|e| e.attributes.get("media_title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&card.name);
|
||||
out.text(
|
||||
title,
|
||||
Rect::new(rect.x + 16., rect.y + 36., rect.w - art_size - 56., 24.),
|
||||
text(18., 600, -0.02),
|
||||
Rect::new(text_x, text_y, text_w, 24.),
|
||||
text(17., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
// Artist
|
||||
// Artist (centered)
|
||||
let artist = entity
|
||||
.and_then(|e| e.attributes.get("media_artist"))
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -2322,32 +2396,47 @@ fn ha_media_card(
|
||||
if !artist.is_empty() {
|
||||
out.text(
|
||||
artist,
|
||||
Rect::new(rect.x + 16., rect.y + 64., rect.w - art_size - 56., 18.),
|
||||
text(14., 400, 0.),
|
||||
Rect::new(text_x, text_y + 28., text_w, 18.),
|
||||
text(13., 400, 0.),
|
||||
c(0xe8e8e8, 0.6),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Volume indicator at bottom
|
||||
// State label
|
||||
let state_str = entity.map_or("off", |e| e.state.as_str());
|
||||
let status_label = match state_str {
|
||||
"playing" => "Playing",
|
||||
"paused" => "Paused",
|
||||
"idle" => "Idle",
|
||||
_ => "Off",
|
||||
};
|
||||
out.text(
|
||||
status_label,
|
||||
Rect::new(text_x, text_y + 52., text_w, 14.),
|
||||
text(11., 400, 0.04),
|
||||
if is_active { accent } else { c(0xe8e8e8, 0.45) },
|
||||
true,
|
||||
);
|
||||
|
||||
// Volume at bottom
|
||||
let vol_pct = (volume * 100.).round() as u8;
|
||||
let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() };
|
||||
out.text(
|
||||
&vol_label,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - art_size - 56., 18.),
|
||||
text(14., 500, 0.),
|
||||
Rect::new(text_x, rect.y + rect.h - 36., text_w, 16.),
|
||||
text(13., 500, 0.),
|
||||
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
// Scroll + OK hint when focused
|
||||
if focused && is_active {
|
||||
out.text(
|
||||
"OK ▶/❚❚ scroll vol",
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 16., rect.w - art_size - 56., 12.),
|
||||
Rect::new(text_x, rect.y + rect.h - 18., text_w, 12.),
|
||||
text(10., 400, 0.02),
|
||||
c(0xeeeeee, 0.30),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user