From c258a0e29ab177c0b0f4d9e6009714a9eeb334db Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Sat, 24 Oct 2026 15:06:22 +0300 Subject: [PATCH] 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 --- src/view.rs | 201 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 145 insertions(+), 56 deletions(-) diff --git a/src/view.rs b/src/view.rs index 679c968..19832f6 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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 = 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, ); } }