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 cols = 4usize;
|
||||||
let card_gap = 16.;
|
let card_gap = 16.;
|
||||||
let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32;
|
let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32;
|
||||||
let card_h = 160.;
|
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() {
|
for (index, card) in room.cards.iter().enumerate() {
|
||||||
let col = index % cols;
|
let tall = matches!(card.card_type, HaCardType::Media);
|
||||||
let row = index / cols;
|
let row_span = if tall { 2 } else { 1 };
|
||||||
let cx = 84. + col as f32 * (card_w + card_gap);
|
|
||||||
let cy = y + row as f32 * (card_h + card_gap);
|
// Find next available cell that fits
|
||||||
let rect = Rect::new(cx, cy, card_w, card_h);
|
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 focused = state.focus == Target::HaCard(state.ha.room, index);
|
||||||
|
|
||||||
let entity = state.ha.entities.get(&card.entity_id);
|
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) {
|
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 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 max_val = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
|
||||||
let range = (max_val - min_val).max(1.);
|
let range = (max_val - min_val).max(0.001);
|
||||||
let step = rect.w / (points.len() as f32 - 1.).max(1.);
|
|
||||||
for i in 0..points.len() {
|
let n = points.len();
|
||||||
let x = rect.x + i as f32 * step;
|
let vals: Vec<f32> = points.iter()
|
||||||
let norm = ((points[i].1 - min_val) / range) as f32;
|
.map(|p| ((p.1 - min_val) / range) as f32)
|
||||||
let bar_h = (norm * rect.h).max(2.);
|
.collect();
|
||||||
let y = rect.y + rect.h - bar_h;
|
|
||||||
|
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(
|
out.shape(
|
||||||
Rect::new(x, y, step.max(2.), bar_h),
|
Rect::new(x, y, col_w + 0.5, h),
|
||||||
0.,
|
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) ────────────────────────────────
|
// ── Action card (scenes, scripts) ────────────────────────────────
|
||||||
@@ -2252,7 +2329,6 @@ fn ha_media_card(
|
|||||||
) {
|
) {
|
||||||
ha_glass(out, rect, focused);
|
ha_glass(out, rect, focused);
|
||||||
let accent = ha_state_color(entity);
|
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");
|
let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused");
|
||||||
|
|
||||||
// Volume fill background
|
// Volume fill background
|
||||||
@@ -2265,10 +2341,23 @@ fn ha_media_card(
|
|||||||
ha_card_fill(out, rect, volume, fill_color);
|
ha_card_fill(out, rect, volume, fill_color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album art circle (right side)
|
// Portrait layout: album art circle centered at top
|
||||||
let art_size = 96.;
|
let art_size = 120.;
|
||||||
let art_x = rect.x + rect.w - art_size - 20.;
|
let art_x = rect.x + (rect.w - art_size) / 2.;
|
||||||
let art_y = rect.y + (rect.h - 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(
|
out.shape(
|
||||||
Rect::new(art_x, art_y, art_size, art_size),
|
Rect::new(art_x, art_y, art_size, art_size),
|
||||||
art_size / 2.,
|
art_size / 2.,
|
||||||
@@ -2277,44 +2366,29 @@ fn ha_media_card(
|
|||||||
out.icon(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
"spotify",
|
"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) },
|
if is_active { accent } else { c(0xbbbbbb, 0.4) },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Media source / state label
|
// Text area below album art
|
||||||
let media_source = entity
|
let text_y = art_y + art_size + 16.;
|
||||||
.and_then(|e| e.attributes.get("media_content_type"))
|
let text_x = rect.x + 16.;
|
||||||
.and_then(|v| v.as_str())
|
let text_w = rect.w - 32.;
|
||||||
.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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Song title
|
// Song title (centered)
|
||||||
let title = entity
|
let title = entity
|
||||||
.and_then(|e| e.attributes.get("media_title"))
|
.and_then(|e| e.attributes.get("media_title"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&card.name);
|
.unwrap_or(&card.name);
|
||||||
out.text(
|
out.text(
|
||||||
title,
|
title,
|
||||||
Rect::new(rect.x + 16., rect.y + 36., rect.w - art_size - 56., 24.),
|
Rect::new(text_x, text_y, text_w, 24.),
|
||||||
text(18., 600, -0.02),
|
text(17., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Artist
|
// Artist (centered)
|
||||||
let artist = entity
|
let artist = entity
|
||||||
.and_then(|e| e.attributes.get("media_artist"))
|
.and_then(|e| e.attributes.get("media_artist"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -2322,32 +2396,47 @@ fn ha_media_card(
|
|||||||
if !artist.is_empty() {
|
if !artist.is_empty() {
|
||||||
out.text(
|
out.text(
|
||||||
artist,
|
artist,
|
||||||
Rect::new(rect.x + 16., rect.y + 64., rect.w - art_size - 56., 18.),
|
Rect::new(text_x, text_y + 28., text_w, 18.),
|
||||||
text(14., 400, 0.),
|
text(13., 400, 0.),
|
||||||
c(0xe8e8e8, 0.6),
|
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_pct = (volume * 100.).round() as u8;
|
||||||
let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() };
|
let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() };
|
||||||
out.text(
|
out.text(
|
||||||
&vol_label,
|
&vol_label,
|
||||||
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - art_size - 56., 18.),
|
Rect::new(text_x, rect.y + rect.h - 36., text_w, 16.),
|
||||||
text(14., 500, 0.),
|
text(13., 500, 0.),
|
||||||
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||||
false,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scroll + OK hint when focused
|
|
||||||
if focused && is_active {
|
if focused && is_active {
|
||||||
out.text(
|
out.text(
|
||||||
"OK ▶/❚❚ scroll vol",
|
"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),
|
text(10., 400, 0.02),
|
||||||
c(0xeeeeee, 0.30),
|
c(0xeeeeee, 0.30),
|
||||||
false,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user