Smooth sparkline, portrait media card, and dynamic grid layout
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s

- 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:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 15:06:22 +03:00
1 parent e29050dc32
commit c258a0e29a
1 file changed
+145 -56
+145 -56
View File
@@ -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,
); );
} }
} }