Compare commits

...
2 Commits
Author SHA1 Message Date
Kaloyan DanchevandClaude Opus 4.6 e29050dc32 Add curtain/blinds cover cards with daylight glow and navy darkening
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
New HaCardType::Curtain and HaCardType::Blinds with:
- Scroll to adjust position (10% steps via cover.set_cover_position)
- OK to toggle open/close
- Dark navy overlay when closed, warm daylight glow when open
- Curtain glow radiates from center, blinds from top

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 14:46:09 +03:00
Kaloyan DanchevandClaude Opus 4.6 c8e6fcb659 Redesign HA cards with fill-based backgrounds and add media player
Navigation: arrow keys now always navigate the card grid (removed
left/right value adjustment from Range/Climate cards). Scroll wheel
on the Magic Remote adjusts range values instead.

Cards: 4-column layout with taller (160px) cards. Each card shows a
fill bar proportional to its value — brightness for lights, target
temperature for climate, volume for media. State-based accent colors
(warm yellow for lights, orange/red for heating, blue for cooling,
purple for media playback).

New card type: Media (media_player) — shows artist, track title,
volume fill, and album art placeholder. OK toggles play/pause,
scroll wheel adjusts volume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 13:48:10 +03:00
5 changed files with 636 additions and 137 deletions

No files matched your search

+3 -2
View File
@@ -39,8 +39,9 @@
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" },
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" },
{ "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" },
{ "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }
{ "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" },
{ "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }
]
},
{
+4
View File
@@ -440,6 +440,10 @@ pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
}
}
if let Some(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) {
ids.push(media.to_owned());
}
if let Some(rooms) = config.get("rooms").and_then(Value::as_array) {
for room in rooms {
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
+84 -27
View File
@@ -327,6 +327,51 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
}),
});
}
HaEffect::MediaPlayPause(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_play_pause".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::MediaNext(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_next_track".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::MediaPrev(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_previous_track".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::SetVolume(entity_id, volume) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "volume_set".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "volume_level": volume }),
});
}
HaEffect::SetCoverPosition(entity_id, position) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "cover".into(),
service: "set_cover_position".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "position": position }),
});
}
}
}
@@ -1027,34 +1072,46 @@ fn run() -> Result<(), String> {
mouse_y,
..
} if state.overlay.is_none() => {
let over_favorites = state.page == state::Page::Home
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
(64. ..1856.).contains(&x)
&& scene.controls.iter().any(|control| {
matches!(control.target, Target::App(_))
&& control.rect.contains(x, y)
})
});
if state.page == state::Page::Home && (x != 0 || over_favorites) {
state.scroll_favorites(
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
);
press = None;
// HA card scroll: adjust range/climate/media values
let ha_scroll = state.page == state::Page::Ha
&& matches!(&state.focus, state::Target::HaCard(room, index)
if matches!(
state.ha.config.rooms[*room].cards[*index].card_type,
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media
))
&& y != 0;
if ha_scroll {
pending = state.scroll_ha(y);
} else {
let max = scene
.controls
.iter()
.filter(|c| c.scrollable)
.map(|c| {
c.rect.y + c.rect.h + state.scroll
- if state.page == state::Page::Home {
1041.6
} else {
982.4
}
})
.fold(0., f32::max);
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
let over_favorites = state.page == state::Page::Home
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
(64. ..1856.).contains(&x)
&& scene.controls.iter().any(|control| {
matches!(control.target, Target::App(_))
&& control.rect.contains(x, y)
})
});
if state.page == state::Page::Home && (x != 0 || over_favorites) {
state.scroll_favorites(
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
);
press = None;
} else {
let max = scene
.controls
.iter()
.filter(|c| c.scrollable)
.map(|c| {
c.rect.y + c.rect.h + state.scroll
- if state.page == state::Page::Home {
1041.6
} else {
982.4
}
})
.fold(0., f32::max);
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
}
}
state.pointer = true;
state.dirty = true;
+67 -25
View File
@@ -161,6 +161,11 @@ pub enum HaEffect {
segments: Vec<u32>,
mode: Option<String>,
},
MediaPlayPause(String),
MediaNext(String),
MediaPrev(String),
SetVolume(String, f32),
SetCoverPosition(String, u8),
}
pub const KEYS: [&str; 40] = [
@@ -176,6 +181,9 @@ pub enum HaCardType {
Climate,
Action,
Vacuum,
Media,
Curtain,
Blinds,
}
#[derive(Clone, Debug)]
@@ -284,6 +292,9 @@ impl HaTabConfig {
Some("climate") => HaCardType::Climate,
Some("action") => HaCardType::Action,
Some("vacuum") => HaCardType::Vacuum,
Some("media") => HaCardType::Media,
Some("curtain") => HaCardType::Curtain,
Some("blinds") => HaCardType::Blinds,
_ => continue,
};
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
@@ -830,34 +841,15 @@ impl State {
}
if let Target::HaCard(room, index) = self.focus {
let room_cfg = &self.ha.config.rooms[room];
let card = &room_cfg.cards[index];
let cols = self.ha_card_columns();
match (&card.card_type, direction) {
(HaCardType::Range, Direction::Left) => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness - 25.5).max(0.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
(HaCardType::Range, Direction::Right) => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness + 25.5).min(255.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
(HaCardType::Climate, Direction::Left) => {
let temp = self.ha_entity_target_temp(&card.entity_id);
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))];
}
(HaCardType::Climate, Direction::Right) => {
let temp = self.ha_entity_target_temp(&card.entity_id);
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))];
}
(_, Direction::Left) if index % cols > 0 => {
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaCard(room, index - 1), controls);
}
(_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
Direction::Right if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
self.focus(Target::HaCard(room, index + 1), controls);
}
(_, Direction::Up) => {
Direction::Up => {
if index >= cols {
self.focus(Target::HaCard(room, index - cols), controls);
} else if self.ha.config.weather.is_some() {
@@ -866,7 +858,7 @@ impl State {
self.focus(Target::HaRoom(self.ha.room), controls);
}
}
(_, Direction::Down) => {
Direction::Down => {
let next = index + cols;
if next < room_cfg.cards.len() {
self.focus(Target::HaCard(room, next), controls);
@@ -1225,7 +1217,7 @@ impl State {
}
fn ha_card_columns(&self) -> usize {
3
4
}
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
@@ -1242,6 +1234,50 @@ impl State {
.unwrap_or(20.) as f32
}
fn ha_entity_volume(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("volume_level"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
fn ha_entity_position(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("current_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
if let Target::HaCard(room, index) = &self.focus {
let card = &self.ha.config.rooms[*room].cards[*index];
match card.card_type {
HaCardType::Range => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness + delta as f32 * 25.5).clamp(0., 255.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
HaCardType::Climate => {
let temp = self.ha_entity_target_temp(&card.entity_id);
let new = temp + delta as f32 * 0.5;
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), new))];
}
HaCardType::Media => {
let vol = self.ha_entity_volume(&card.entity_id);
let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))];
}
HaCardType::Curtain | HaCardType::Blinds => {
let pos = self.ha_entity_position(&card.entity_id);
let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8;
return vec![Effect::Ha(HaEffect::SetCoverPosition(card.entity_id.clone(), new))];
}
_ => {}
}
}
vec![]
}
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let vac = match &self.ha.config.vacuum {
Some(v) => v,
@@ -1670,6 +1706,12 @@ impl State {
self.focus = Target::HaVacuumRoom(0);
}
}
HaCardType::Media => {
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))];
}
HaCardType::Curtain | HaCardType::Blinds => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
_ => {}
}
}
+478 -83
View File
@@ -1677,10 +1677,10 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
}
// ── Card grid ────────────────────────────────────────────────
let cols = 3usize;
let card_w = 564.;
let cols = 4usize;
let card_gap = 16.;
let card_h = 88.;
let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32;
let card_h = 160.;
for (index, card) in room.cards.iter().enumerate() {
let col = index % cols;
@@ -1707,6 +1707,15 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
HaCardType::Vacuum => {
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Media => {
ha_media_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Curtain => {
ha_cover_card(out, state, assets, rect, card, entity, focused, true);
}
HaCardType::Blinds => {
ha_cover_card(out, state, assets, rect, card, entity, focused, false);
}
}
out.control(Target::HaCard(state.ha.room, index), rect, true);
}
@@ -1752,6 +1761,9 @@ fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'st
HaCardType::Climate => "thermometer",
HaCardType::Action => "scene",
HaCardType::Vacuum => "vacuum",
HaCardType::Media => "spotify",
HaCardType::Curtain => "curtain",
HaCardType::Blinds => "curtain",
}
}
@@ -1760,10 +1772,66 @@ fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color {
Some("on") => c(0x7EB8DA, 1.),
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
Some("cool") => c(0x60b0f0, 1.),
Some("playing") => c(0x9b7ddb, 1.),
Some("paused") => c(0x7d9bdb, 0.7),
_ => c(0xbbbbbb, 0.5),
}
}
fn ha_fill_color(card_type: &crate::state::HaCardType, entity: Option<&crate::state::HaEntity>) -> Color {
use crate::state::HaCardType;
match card_type {
HaCardType::Range => c(0xfab95c, 0.30),
HaCardType::Climate => {
match entity.map(|e| e.state.as_str()) {
Some("heat" | "heat_cool" | "auto") => c(0xf06040, 0.28),
Some("cool") => c(0x4090f0, 0.28),
_ => c(0xf0a060, 0.15),
}
}
HaCardType::Toggle => {
let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open");
if is_on {
if entity.is_some_and(|e| e.attributes.get("device_class").and_then(|v| v.as_str()) == Some("lock")) {
c(0x50b860, 0.28)
} else {
c(0xfab95c, 0.25)
}
} else {
c(0, 0.)
}
}
HaCardType::Media => c(0x8060c0, 0.25),
HaCardType::Curtain | HaCardType::Blinds => {
let pos = entity
.and_then(|e| e.attributes.get("current_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
if pos > 5. {
c(0xdcd2b4, (0.15 + pos as f32 / 100. * 0.25).min(0.40))
} else {
c(0x0c1024, 0.50)
}
}
HaCardType::Vacuum => {
match entity.map(|e| e.state.as_str()) {
Some("cleaning") => c(0x7EB8DA, 0.25),
Some("returning") => c(0xf0a060, 0.20),
_ => c(0, 0.),
}
}
_ => c(0, 0.),
}
}
fn ha_card_fill(out: &mut Scene, rect: Rect, fill_pct: f32, color: Color) {
if fill_pct <= 0.01 || color[3] < 0.01 { return; }
let pct = fill_pct.clamp(0., 1.);
let fill_w = (rect.w * pct).max(44.);
let radius = 22f32.min(fill_w / 2.);
out.shape(Rect::new(rect.x, rect.y, fill_w, rect.h), radius, color);
}
// ── Toggle card ──────────────────────────────────────────────────
fn ha_toggle_card(
@@ -1778,37 +1846,67 @@ fn ha_toggle_card(
ha_glass(out, rect, focused);
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
let accent = ha_state_color(entity);
let is_on = entity.is_some_and(|e| e.state == "on");
let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open");
let fill_color = ha_fill_color(&card.card_type, entity);
if is_on {
ha_card_fill(out, rect, 1., fill_color);
}
// Icon in tinted circle
let icon_bg = if is_on { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) };
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
icon_name,
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
if is_on { accent } else { c(0xbbbbbb, 0.5) },
);
// Type label (small)
let type_label = if card.entity_id.starts_with("light.") {
"Light"
} else if card.entity_id.starts_with("cover.") {
"Cover"
} else if card.entity_id.starts_with("switch.") {
"Switch"
} else {
"Toggle"
};
out.text(
type_label,
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
if is_on { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
text(20., 500, -0.02),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
let status = entity.map_or("unavailable", |e| e.state.as_str());
// Status at bottom left
let status_text = if is_on { "On" } else { entity.map_or("Unavailable", |e| e.state.as_str()) };
let status_text = match status_text {
"off" => "Off",
"on" => "On",
"open" => "Open",
"closed" => "Closed",
"locked" => "Locked",
"unlocked" => "Unlocked",
other => other,
};
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
status_text,
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
text(22., 700, -0.02),
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
// On/off indicator dot
let dot_x = rect.x + rect.w - 36.;
let dot_y = rect.y + rect.h / 2. - 8.;
out.shape(
Rect::new(dot_x, dot_y, 16., 16.),
8.,
if is_on { accent } else { c(0x555555, 0.6) },
);
}
// ── Range card (dimmable lights, cover position) ─────────────────
@@ -1829,44 +1927,60 @@ fn ha_range_card(
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let pct = (brightness / 255. * 100.).round() as u8;
let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) };
let accent = if is_on { c(0xfab95c, 1.) } else { c(0xbbbbbb, 0.5) };
// Fill background proportional to brightness
if is_on && pct > 0 {
let fill_color = ha_fill_color(&card.card_type, entity);
ha_card_fill(out, rect, pct as f32 / 100., fill_color);
}
// Icon in tinted circle
let icon_bg = if is_on { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) };
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
"light",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
);
// Type label
out.text(
"Light",
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
if is_on { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
text(20., 500, -0.02),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
// Brightness bar
let bar_x = rect.x + 80.;
let bar_y = rect.y + 52.;
let bar_w = rect.w - 160.;
let bar_h = 8.;
out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08));
if is_on && pct > 0 {
let fill_w = bar_w * (pct as f32 / 100.).min(1.);
out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent);
}
// Percentage label
// Value at bottom left
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
out.text(
&label,
Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.),
text(15.2, 500, 0.).right(),
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
text(22., 700, -0.02),
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
if focused {
// D-pad hint arrows
out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
// Scroll hint when focused
if focused && is_on {
out.text(
"scroll",
Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.),
text(11., 400, 0.03).right(),
c(0xeeeeee, 0.35),
false,
);
}
}
@@ -1892,53 +2006,89 @@ fn ha_climate_card(
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
let is_active = hvac_mode != "off" && hvac_mode != "unavailable";
// Fill proportional to target temp (15-35°C range)
if is_active {
let fill_pct = ((target_temp - 15.) / 20.).clamp(0., 1.) as f32;
let fill_color = ha_fill_color(&card.card_type, entity);
ha_card_fill(out, rect, fill_pct, fill_color);
}
// Icon in tinted circle
let icon_bg = if is_active { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) };
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
"thermometer",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
);
// Mode label
let mode_label = match hvac_mode {
"heat" => "Heat",
"cool" => "Cool",
"heat_cool" | "auto" => "Auto",
"off" => "Off",
other => other,
};
out.text(
mode_label,
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
if is_active { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
text(20., 500, -0.02),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
// Current temp small label
let now_str = format!("Now {:.0}°C", current_temp);
out.text(
hvac_mode,
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
&now_str,
Rect::new(rect.x + 16., rect.y + rect.h - 60., rect.w - 32., 16.),
text(12., 400, 0.02),
c(0xe8e8e8, 0.55),
false,
);
// Current temperature
let current_str = format!("{:.1}°", current_temp);
// Target/current temperature large
let temp_str = format!("{:.0}°", target_temp);
out.text(
&current_str,
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
text(24., 600, -0.03).right(),
WHITE,
&temp_str,
Rect::new(rect.x + 16., rect.y + rect.h - 42., rect.w - 32., 30.),
text(26., 700, -0.02),
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
// Target temperature
let target_str = format!("→ {:.1}°", target_temp);
out.text(
&target_str,
Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.),
text(18., 400, 0.),
accent,
false,
);
if focused {
out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
// Scroll hint when focused
if focused && is_active {
out.text(
"scroll",
Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.),
text(11., 400, 0.03).right(),
c(0xeeeeee, 0.35),
false,
);
}
// History sparkline if available
if let Some(points) = state.ha.history.get(&card.entity_id) {
if points.len() >= 2 {
ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent);
ha_sparkline(
out,
Rect::new(rect.x + 76., rect.y + 64., rect.w - 92., 20.),
points,
[accent[0], accent[1], accent[2], 0.5],
);
}
}
}
@@ -1974,24 +2124,41 @@ fn ha_action_card(
) {
ha_glass(out, rect, focused);
let accent = c(0xc9a0dc, 1.);
// Icon in tinted circle
let icon_bg = [accent[0], accent[1], accent[2], 0.15];
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
"scene",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
);
// Type label
let type_label = if card.entity_id.starts_with("script.") { "Script" } else { "Scene" };
out.text(
type_label,
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
[accent[0], accent[1], accent[2], 0.7],
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
text(20., 500, -0.02),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
// Action hint
if focused {
out.text(
"OK to activate",
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
text(12., 400, 0.),
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - 32., 16.),
text(12., 400, 0.02),
c(0xe8e8e8, 0.40),
false,
);
@@ -2016,32 +2183,260 @@ fn ha_vacuum_card(
"returning" => c(0xf0a060, 1.),
_ => c(0xbbbbbb, 0.5),
};
// Fill for active states
let fill_color = ha_fill_color(&card.card_type, entity);
if status == "cleaning" || status == "returning" {
ha_card_fill(out, rect, if status == "cleaning" { 0.6 } else { 0.3 }, fill_color);
}
// Icon in tinted circle
let icon_bg = if status == "cleaning" || status == "returning" {
[accent[0], accent[1], accent[2], 0.2]
} else {
c(0xffffff, 0.08)
};
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
"vacuum",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
);
// Type label
out.text(
"Vacuum",
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
if status == "cleaning" { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
text(20., 500, -0.02),
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
// Status at bottom
let display_status = match status {
"cleaning" => "Cleaning",
"returning" => "Returning",
"docked" => "Docked",
"idle" => "Idle",
"paused" => "Paused",
other => other,
};
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
display_status,
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
text(22., 700, -0.02),
if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
}
// ── Media card (Music Assistant / media_player) ────────────────
fn ha_media_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
) {
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
let volume = entity
.and_then(|e| e.attributes.get("volume_level"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32;
if is_active && volume > 0. {
let fill_color = ha_fill_color(&card.card_type, entity);
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.;
out.shape(
Rect::new(art_x, art_y, art_size, art_size),
art_size / 2.,
if is_active { [accent[0], accent[1], accent[2], 0.25] } else { c(0xffffff, 0.08) },
);
out.icon(
assets,
"spotify",
Rect::new(art_x + 24., art_y + 24., 48., 48.),
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,
);
// Song title
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),
WHITE,
false,
);
// Artist
let artist = entity
.and_then(|e| e.attributes.get("media_artist"))
.and_then(|v| v.as_str())
.unwrap_or("");
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.),
c(0xe8e8e8, 0.6),
false,
);
}
// Volume indicator 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.),
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
// 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.),
text(10., 400, 0.02),
c(0xeeeeee, 0.30),
false,
);
}
}
// ── Cover card (curtain / blinds) ────────────────────────────────
fn ha_cover_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
is_curtain: bool,
) {
ha_glass(out, rect, focused);
let position = entity
.and_then(|e| e.attributes.get("current_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32;
let is_open = position > 5.;
// Dark navy overlay when closed (proportional to how closed it is)
let closed_pct = 1. - (position / 100.).clamp(0., 1.);
if closed_pct > 0.05 {
out.shape(rect, 22., c(0x0c1024, 0.5 * closed_pct));
}
// Daylight glow when open
if is_open {
let open_pct = (position / 100.).clamp(0., 1.);
let fill_color = ha_fill_color(&card.card_type, entity);
if is_curtain {
// Curtain: glow from center
ha_card_fill(out, rect, open_pct, fill_color);
} else {
// Blinds: glow from top
ha_card_fill(out, rect, open_pct, fill_color);
}
}
let accent = if is_open { c(0xdcd2b4, 1.) } else { c(0xbbbbbb, 0.5) };
// Icon
let icon_bg = if is_open { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) };
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
out.icon(
assets,
ha_entity_icon(&card.card_type, &card.entity_id),
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
accent,
);
// Type label
let type_label = if is_curtain { "Curtain" } else { "Blinds" };
out.text(
type_label,
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
text(12., 400, 0.04),
if is_open { accent } else { c(0xe8e8e8, 0.45) },
false,
);
// Entity name
out.text(
&card.name,
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
text(18., 600, -0.02),
WHITE,
false,
);
// Position value at bottom
let label = if position > 0. { format!("{}%", position.round() as u8) } else { "Closed".into() };
out.text(
&label,
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
text(22., 700, -0.02),
if is_open { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
// Scroll hint
if focused {
out.text(
"OK to open",
Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.),
text(12., 400, 0.).right(),
c(0xe8e8e8, 0.40),
"scroll",
Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.),
text(11., 400, 0.03).right(),
c(0xeeeeee, 0.35),
false,
);
}