Compare commits
2
Commits
a7dd876c63
...
e29050dc32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29050dc32 | ||
|
|
c8e6fcb659 |
No files matched your search
+3
-2
@@ -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_z1", "name": "LED Strip 1" },
|
||||||
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" },
|
{ "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": "light.picture_frame_lamp", "name": "Picture Lamp" },
|
||||||
{ "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
|
{ "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
|
||||||
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }
|
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" },
|
||||||
|
{ "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {
|
if let Some(rooms) = config.get("rooms").and_then(Value::as_array) {
|
||||||
for room in rooms {
|
for room in rooms {
|
||||||
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
|
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
|
||||||
|
|||||||
+84
-27
@@ -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,
|
mouse_y,
|
||||||
..
|
..
|
||||||
} if state.overlay.is_none() => {
|
} if state.overlay.is_none() => {
|
||||||
let over_favorites = state.page == state::Page::Home
|
// HA card scroll: adjust range/climate/media values
|
||||||
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
let ha_scroll = state.page == state::Page::Ha
|
||||||
(64. ..1856.).contains(&x)
|
&& matches!(&state.focus, state::Target::HaCard(room, index)
|
||||||
&& scene.controls.iter().any(|control| {
|
if matches!(
|
||||||
matches!(control.target, Target::App(_))
|
state.ha.config.rooms[*room].cards[*index].card_type,
|
||||||
&& control.rect.contains(x, y)
|
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media
|
||||||
})
|
))
|
||||||
});
|
&& y != 0;
|
||||||
if state.page == state::Page::Home && (x != 0 || over_favorites) {
|
if ha_scroll {
|
||||||
state.scroll_favorites(
|
pending = state.scroll_ha(y);
|
||||||
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
|
|
||||||
);
|
|
||||||
press = None;
|
|
||||||
} else {
|
} else {
|
||||||
let max = scene
|
let over_favorites = state.page == state::Page::Home
|
||||||
.controls
|
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
||||||
.iter()
|
(64. ..1856.).contains(&x)
|
||||||
.filter(|c| c.scrollable)
|
&& scene.controls.iter().any(|control| {
|
||||||
.map(|c| {
|
matches!(control.target, Target::App(_))
|
||||||
c.rect.y + c.rect.h + state.scroll
|
&& control.rect.contains(x, y)
|
||||||
- if state.page == state::Page::Home {
|
})
|
||||||
1041.6
|
});
|
||||||
} else {
|
if state.page == state::Page::Home && (x != 0 || over_favorites) {
|
||||||
982.4
|
state.scroll_favorites(
|
||||||
}
|
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
|
||||||
})
|
);
|
||||||
.fold(0., f32::max);
|
press = None;
|
||||||
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
|
} 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.pointer = true;
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
|
|||||||
+67
-25
@@ -161,6 +161,11 @@ pub enum HaEffect {
|
|||||||
segments: Vec<u32>,
|
segments: Vec<u32>,
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
},
|
},
|
||||||
|
MediaPlayPause(String),
|
||||||
|
MediaNext(String),
|
||||||
|
MediaPrev(String),
|
||||||
|
SetVolume(String, f32),
|
||||||
|
SetCoverPosition(String, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const KEYS: [&str; 40] = [
|
pub const KEYS: [&str; 40] = [
|
||||||
@@ -176,6 +181,9 @@ pub enum HaCardType {
|
|||||||
Climate,
|
Climate,
|
||||||
Action,
|
Action,
|
||||||
Vacuum,
|
Vacuum,
|
||||||
|
Media,
|
||||||
|
Curtain,
|
||||||
|
Blinds,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -284,6 +292,9 @@ impl HaTabConfig {
|
|||||||
Some("climate") => HaCardType::Climate,
|
Some("climate") => HaCardType::Climate,
|
||||||
Some("action") => HaCardType::Action,
|
Some("action") => HaCardType::Action,
|
||||||
Some("vacuum") => HaCardType::Vacuum,
|
Some("vacuum") => HaCardType::Vacuum,
|
||||||
|
Some("media") => HaCardType::Media,
|
||||||
|
Some("curtain") => HaCardType::Curtain,
|
||||||
|
Some("blinds") => HaCardType::Blinds,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
|
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 {
|
if let Target::HaCard(room, index) = self.focus {
|
||||||
let room_cfg = &self.ha.config.rooms[room];
|
let room_cfg = &self.ha.config.rooms[room];
|
||||||
let card = &room_cfg.cards[index];
|
|
||||||
let cols = self.ha_card_columns();
|
let cols = self.ha_card_columns();
|
||||||
match (&card.card_type, direction) {
|
match direction {
|
||||||
(HaCardType::Range, Direction::Left) => {
|
Direction::Left if index % cols > 0 => {
|
||||||
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 => {
|
|
||||||
self.focus(Target::HaCard(room, index - 1), controls);
|
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);
|
self.focus(Target::HaCard(room, index + 1), controls);
|
||||||
}
|
}
|
||||||
(_, Direction::Up) => {
|
Direction::Up => {
|
||||||
if index >= cols {
|
if index >= cols {
|
||||||
self.focus(Target::HaCard(room, index - cols), controls);
|
self.focus(Target::HaCard(room, index - cols), controls);
|
||||||
} else if self.ha.config.weather.is_some() {
|
} else if self.ha.config.weather.is_some() {
|
||||||
@@ -866,7 +858,7 @@ impl State {
|
|||||||
self.focus(Target::HaRoom(self.ha.room), controls);
|
self.focus(Target::HaRoom(self.ha.room), controls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(_, Direction::Down) => {
|
Direction::Down => {
|
||||||
let next = index + cols;
|
let next = index + cols;
|
||||||
if next < room_cfg.cards.len() {
|
if next < room_cfg.cards.len() {
|
||||||
self.focus(Target::HaCard(room, next), controls);
|
self.focus(Target::HaCard(room, next), controls);
|
||||||
@@ -1225,7 +1217,7 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ha_card_columns(&self) -> usize {
|
fn ha_card_columns(&self) -> usize {
|
||||||
3
|
4
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
|
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
|
||||||
@@ -1242,6 +1234,50 @@ impl State {
|
|||||||
.unwrap_or(20.) as f32
|
.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> {
|
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
|
||||||
let vac = match &self.ha.config.vacuum {
|
let vac = match &self.ha.config.vacuum {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
@@ -1670,6 +1706,12 @@ impl State {
|
|||||||
self.focus = Target::HaVacuumRoom(0);
|
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
@@ -1677,10 +1677,10 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Card grid ────────────────────────────────────────────────
|
// ── Card grid ────────────────────────────────────────────────
|
||||||
let cols = 3usize;
|
let cols = 4usize;
|
||||||
let card_w = 564.;
|
|
||||||
let card_gap = 16.;
|
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() {
|
for (index, card) in room.cards.iter().enumerate() {
|
||||||
let col = index % cols;
|
let col = index % cols;
|
||||||
@@ -1707,6 +1707,15 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
|||||||
HaCardType::Vacuum => {
|
HaCardType::Vacuum => {
|
||||||
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
|
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);
|
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::Climate => "thermometer",
|
||||||
HaCardType::Action => "scene",
|
HaCardType::Action => "scene",
|
||||||
HaCardType::Vacuum => "vacuum",
|
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("on") => c(0x7EB8DA, 1.),
|
||||||
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
|
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
|
||||||
Some("cool") => c(0x60b0f0, 1.),
|
Some("cool") => c(0x60b0f0, 1.),
|
||||||
|
Some("playing") => c(0x9b7ddb, 1.),
|
||||||
|
Some("paused") => c(0x7d9bdb, 0.7),
|
||||||
_ => c(0xbbbbbb, 0.5),
|
_ => 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 ──────────────────────────────────────────────────
|
// ── Toggle card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn ha_toggle_card(
|
fn ha_toggle_card(
|
||||||
@@ -1778,37 +1846,67 @@ fn ha_toggle_card(
|
|||||||
ha_glass(out, rect, focused);
|
ha_glass(out, rect, focused);
|
||||||
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
|
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
|
||||||
let accent = ha_state_color(entity);
|
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(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
icon_name,
|
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) },
|
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(
|
out.text(
|
||||||
&card.name,
|
&card.name,
|
||||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
|
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||||
text(20., 500, -0.02),
|
text(18., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
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(
|
out.text(
|
||||||
status,
|
status_text,
|
||||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||||
text(13.12, 400, 0.),
|
text(22., 700, -0.02),
|
||||||
c(0xe8e8e8, 0.48),
|
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||||
false,
|
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) ─────────────────
|
// ── Range card (dimmable lights, cover position) ─────────────────
|
||||||
@@ -1829,44 +1927,60 @@ fn ha_range_card(
|
|||||||
.and_then(|v| v.as_f64())
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or(0.);
|
.unwrap_or(0.);
|
||||||
let pct = (brightness / 255. * 100.).round() as u8;
|
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(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
"light",
|
"light",
|
||||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||||
accent,
|
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(
|
out.text(
|
||||||
&card.name,
|
&card.name,
|
||||||
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
|
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||||
text(20., 500, -0.02),
|
text(18., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
// Brightness bar
|
|
||||||
let bar_x = rect.x + 80.;
|
// Value at bottom left
|
||||||
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
|
|
||||||
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
|
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
|
||||||
out.text(
|
out.text(
|
||||||
&label,
|
&label,
|
||||||
Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||||
text(15.2, 500, 0.).right(),
|
text(22., 700, -0.02),
|
||||||
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
if focused {
|
|
||||||
// D-pad hint arrows
|
// Scroll hint when focused
|
||||||
out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
if focused && is_on {
|
||||||
out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
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())
|
.and_then(|v| v.as_f64())
|
||||||
.unwrap_or(0.);
|
.unwrap_or(0.);
|
||||||
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
|
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(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
"thermometer",
|
"thermometer",
|
||||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||||
accent,
|
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(
|
out.text(
|
||||||
&card.name,
|
&card.name,
|
||||||
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
|
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||||
text(20., 500, -0.02),
|
text(18., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Current temp small label
|
||||||
|
let now_str = format!("Now {:.0}°C", current_temp);
|
||||||
out.text(
|
out.text(
|
||||||
hvac_mode,
|
&now_str,
|
||||||
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 60., rect.w - 32., 16.),
|
||||||
text(13.12, 400, 0.),
|
text(12., 400, 0.02),
|
||||||
c(0xe8e8e8, 0.48),
|
c(0xe8e8e8, 0.55),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
// Current temperature
|
|
||||||
let current_str = format!("{:.1}°", current_temp);
|
// Target/current temperature large
|
||||||
|
let temp_str = format!("{:.0}°", target_temp);
|
||||||
out.text(
|
out.text(
|
||||||
¤t_str,
|
&temp_str,
|
||||||
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 42., rect.w - 32., 30.),
|
||||||
text(24., 600, -0.03).right(),
|
text(26., 700, -0.02),
|
||||||
WHITE,
|
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
// Target temperature
|
|
||||||
let target_str = format!("→ {:.1}°", target_temp);
|
// Scroll hint when focused
|
||||||
out.text(
|
if focused && is_active {
|
||||||
&target_str,
|
out.text(
|
||||||
Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.),
|
"scroll",
|
||||||
text(18., 400, 0.),
|
Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.),
|
||||||
accent,
|
text(11., 400, 0.03).right(),
|
||||||
false,
|
c(0xeeeeee, 0.35),
|
||||||
);
|
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// History sparkline if available
|
// History sparkline if available
|
||||||
if let Some(points) = state.ha.history.get(&card.entity_id) {
|
if let Some(points) = state.ha.history.get(&card.entity_id) {
|
||||||
if points.len() >= 2 {
|
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);
|
ha_glass(out, rect, focused);
|
||||||
let accent = c(0xc9a0dc, 1.);
|
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(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
"scene",
|
"scene",
|
||||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||||
accent,
|
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(
|
out.text(
|
||||||
&card.name,
|
&card.name,
|
||||||
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
|
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||||
text(20., 500, -0.02),
|
text(18., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Action hint
|
||||||
if focused {
|
if focused {
|
||||||
out.text(
|
out.text(
|
||||||
"OK to activate",
|
"OK to activate",
|
||||||
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - 32., 16.),
|
||||||
text(12., 400, 0.),
|
text(12., 400, 0.02),
|
||||||
c(0xe8e8e8, 0.40),
|
c(0xe8e8e8, 0.40),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -2016,32 +2183,260 @@ fn ha_vacuum_card(
|
|||||||
"returning" => c(0xf0a060, 1.),
|
"returning" => c(0xf0a060, 1.),
|
||||||
_ => c(0xbbbbbb, 0.5),
|
_ => 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(
|
out.icon(
|
||||||
assets,
|
assets,
|
||||||
"vacuum",
|
"vacuum",
|
||||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||||
accent,
|
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(
|
out.text(
|
||||||
&card.name,
|
&card.name,
|
||||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
|
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||||
text(20., 500, -0.02),
|
text(18., 600, -0.02),
|
||||||
WHITE,
|
WHITE,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Status at bottom
|
||||||
|
let display_status = match status {
|
||||||
|
"cleaning" => "Cleaning",
|
||||||
|
"returning" => "Returning",
|
||||||
|
"docked" => "Docked",
|
||||||
|
"idle" => "Idle",
|
||||||
|
"paused" => "Paused",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
out.text(
|
out.text(
|
||||||
status,
|
display_status,
|
||||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
|
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||||
text(13.12, 400, 0.),
|
text(22., 700, -0.02),
|
||||||
c(0xe8e8e8, 0.48),
|
if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||||
false,
|
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 {
|
if focused {
|
||||||
out.text(
|
out.text(
|
||||||
"OK to open",
|
"scroll",
|
||||||
Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.),
|
Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.),
|
||||||
text(12., 400, 0.).right(),
|
text(11., 400, 0.03).right(),
|
||||||
c(0xe8e8e8, 0.40),
|
c(0xeeeeee, 0.35),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user