Add glass card rendering and 6 card types for HA tab

Implement the full Home Assistant tab with room-based navigation
and glass-morphism card rendering:

- Toggle card: on/off for lights, switches, covers with state indicator
- Range card: dimmable lights with brightness bar and D-pad ±10% control
- Climate card: current/target temp, HVAC mode, D-pad ±0.5°C, sparkline
- Action card: one-shot activation for scenes and scripts
- Vacuum card: summary card that opens room selection modal
- Weather card: hero card with condition icon, temp, humidity, wind

Navigation: room tabs (horizontal pills), card grid (3-col), vacuum
modal with room multi-select, cleaning mode cycle, and start button.
Back button cascades: vacuum modal → card grid → room tabs → Home page.

Glass design: rgba(255,255,255,0.12) fill, 22px radius, 1px border at
0.15 opacity, elevated shadow on focus. Accent color #7EB8DA for active
entities, warm orange for heating, cool blue for cooling.

Wires Effect::Ha to dispatch toggle/brightness/temperature/scene/vacuum
service calls through ha_client. Adds 6 new SVG icons matching the
existing Lucide stroke style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 12:14:07 +03:00
1 parent 98dd75ab0e
commit 764af371b9
9 files changed
+1183 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M2 4h20"></path><path d="M2 4v16"></path><path d="M22 4v16"></path><path d="M5 4c0 6 3 8 7 8"></path><path d="M19 4c0 6-3 8-7 8"></path><path d="M5 4c0 10 3 16 7 16"></path><path d="M19 4c0 10-3 16-7 16"></path></svg>

After

Width:  |  Height:  |  Size: 426 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"></path></svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path><path d="M9 18h6"></path><path d="M10 22h4"></path></svg>

After

Width:  |  Height:  |  Size: 375 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h.01"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>

After

Width:  |  Height:  |  Size: 459 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><rect x="2" y="6" width="20" height="12" rx="6"></rect><circle cx="16" cy="12" r="2"></circle></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><circle cx="12" cy="12" r="9"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 9V4"></path><path d="m14.6 10.5 4.3-2.5"></path><path d="m14.6 13.5 4.3 2.5"></path><path d="M12 15v5"></path><path d="m9.4 13.5-4.3 2.5"></path><path d="m9.4 10.5-4.3-2.5"></path></svg>

After

Width:  |  Height:  |  Size: 477 B

+105
View File
@@ -178,6 +178,7 @@ fn effects(
platform: &Platform,
preferences: &std::path::Path,
weather: &weather_service::Service,
ha: Option<&ha_client::HaService>,
) -> bool {
for effect in effects {
match effect {
@@ -225,11 +226,110 @@ fn effects(
}
Effect::Quit => return false,
Effect::Weather(command) => weather.send(command),
Effect::Ha(ha_effect) => {
if let Some(service) = ha {
ha_dispatch(service, ha_effect);
}
}
}
}
true
}
fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
use state::HaEffect;
static CALL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000);
let next_id = || CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
match effect {
HaEffect::Toggle(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("light.") {
("light", "toggle")
} else if entity_id.starts_with("switch.") {
("switch", "toggle")
} else if entity_id.starts_with("cover.") {
("cover", "toggle")
} else {
("homeassistant", "toggle")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::SetBrightness(entity_id, brightness) => {
if brightness <= 0. {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_off".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
} else {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_on".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "brightness": brightness as u8 }),
});
}
}
HaEffect::SetClimateTemp(entity_id, temp) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "climate".into(),
service: "set_temperature".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "temperature": temp }),
});
}
HaEffect::ActivateScene(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("script.") {
("script", "turn_on")
} else {
("scene", "turn_on")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::VacuumClean { entity_id, segments, mode } => {
if let Some(mode_name) = &mode {
let cleaning_mode_entity = format!(
"select.{}_cleaning_mode",
entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id)
);
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "select".into(),
service: "select_option".into(),
target: serde_json::json!({ "entity_id": cleaning_mode_entity }),
data: serde_json::json!({ "option": mode_name }),
});
}
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "vacuum".into(),
service: "send_command".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({
"command": "app_segment_clean",
"params": { "segments": segments, "repeat": 1 }
}),
});
}
}
}
fn script(
action: &str,
state: &mut State,
@@ -524,6 +624,7 @@ fn run() -> Result<(), String> {
};
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
if let Ok(config_value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
state.ha.config = state::HaTabConfig::parse(&config_value);
let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
if !url.is_empty() && !token.is_empty() {
@@ -555,6 +656,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
platform.sync_splash(&state.preferences.background);
startup_stage("preferences and workers");
@@ -965,6 +1067,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
if press
.as_ref()
@@ -1008,6 +1111,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
}
@@ -1029,6 +1133,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
let keyboard = state.text_input_active();
+406 -1
View File
@@ -62,6 +62,14 @@ pub enum Target {
WeatherSearch,
WeatherClear,
WeatherResult(usize),
HaRoom(usize),
HaCard(usize, usize),
HaWeather,
HaVacuumOpen,
HaVacuumRoom(usize),
HaVacuumMode,
HaVacuumStart,
HaVacuumClose,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -139,6 +147,20 @@ pub enum Effect {
Launch(String),
Quit,
Weather(weather::Command),
Ha(HaEffect),
}
#[derive(Debug, PartialEq)]
pub enum HaEffect {
Toggle(String),
SetBrightness(String, f32),
SetClimateTemp(String, f32),
ActivateScene(String),
VacuumClean {
entity_id: String,
segments: Vec<u32>,
mode: Option<String>,
},
}
pub const KEYS: [&str; 40] = [
@@ -147,11 +169,64 @@ pub const KEYS: [&str; 40] = [
"Delete", "Clear", "Done",
];
#[derive(Clone, Debug)]
pub enum HaCardType {
Toggle,
Range,
Climate,
Action,
Vacuum,
}
#[derive(Clone, Debug)]
pub struct HaCardConfig {
pub card_type: HaCardType,
pub entity_id: String,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct HaRoomConfig {
pub id: String,
pub name: String,
pub cards: Vec<HaCardConfig>,
}
#[derive(Clone, Debug, Default)]
pub struct HaWeatherConfig {
pub entity_id: String,
}
#[derive(Clone, Debug)]
pub struct HaVacuumRoom {
pub segment_id: u32,
pub name: String,
}
#[derive(Clone, Debug, Default)]
pub struct HaVacuumConfig {
pub entity_id: String,
pub cleaning_mode_entity: String,
pub rooms: Vec<HaVacuumRoom>,
}
#[derive(Clone, Debug, Default)]
pub struct HaTabConfig {
pub rooms: Vec<HaRoomConfig>,
pub weather: Option<HaWeatherConfig>,
pub vacuum: Option<HaVacuumConfig>,
}
#[derive(Default)]
pub struct HaState {
pub connected: bool,
pub entities: std::collections::HashMap<String, HaEntity>,
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
pub config: HaTabConfig,
pub room: usize,
pub vacuum_selected: Vec<bool>,
pub vacuum_mode: usize,
pub vacuum_open: bool,
}
#[derive(Clone, Debug)]
@@ -160,6 +235,74 @@ pub struct HaEntity {
pub attributes: serde_json::Value,
}
impl HaTabConfig {
pub fn parse(value: &serde_json::Value) -> Self {
let mut config = HaTabConfig::default();
if let Some(w) = value.get("weather") {
if let Some(eid) = w.get("entity_id").and_then(|v| v.as_str()) {
config.weather = Some(HaWeatherConfig {
entity_id: eid.to_owned(),
});
}
}
if let Some(v) = value.get("vacuum") {
let mut vc = HaVacuumConfig::default();
if let Some(eid) = v.get("entity_id").and_then(|v| v.as_str()) {
vc.entity_id = eid.to_owned();
}
if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) {
vc.cleaning_mode_entity = cme.to_owned();
}
if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) {
for (id, name) in rooms {
if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) {
vc.rooms.push(HaVacuumRoom {
segment_id: seg,
name: n.to_owned(),
});
}
}
vc.rooms.sort_by_key(|r| r.segment_id);
}
if !vc.entity_id.is_empty() {
config.vacuum = Some(vc);
}
}
if let Some(rooms) = value.get("rooms").and_then(|v| v.as_array()) {
for room in rooms {
let id = room.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let name = room.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let mut cards = Vec::new();
if let Some(card_arr) = room.get("cards").and_then(|v| v.as_array()) {
for card in card_arr {
let ct = match card.get("type").and_then(|v| v.as_str()) {
Some("toggle") => HaCardType::Toggle,
Some("range") => HaCardType::Range,
Some("climate") => HaCardType::Climate,
Some("action") => HaCardType::Action,
Some("vacuum") => HaCardType::Vacuum,
_ => continue,
};
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let n = card.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
cards.push(HaCardConfig {
card_type: ct,
entity_id: eid,
name: n,
});
}
}
config.rooms.push(HaRoomConfig { id, name, cards });
}
}
config
}
}
pub struct State {
pub page: Page,
pub focus: Target,
@@ -410,7 +553,13 @@ impl State {
.first()
.map(|app| Target::App(app.id.clone()))
.unwrap_or(Target::Filter(1)),
Page::Ha => Target::Nav(2),
Page::Ha => {
if self.ha.config.rooms.is_empty() {
Target::Nav(2)
} else {
Target::HaRoom(self.ha.room)
}
}
Page::Settings => Target::Sidebar(0),
}
}
@@ -636,6 +785,99 @@ impl State {
return vec![];
}
}
// ── HA page navigation ────────────────────────────────
if self.page == Page::Ha && !self.ha.config.rooms.is_empty() {
if self.ha.vacuum_open {
return self.ha_vacuum_direction(direction, controls);
}
if let Target::HaRoom(room) = self.focus {
match direction {
Direction::Left if room > 0 => {
self.ha.room = room - 1;
self.focus(Target::HaRoom(room - 1), controls);
self.scroll = 0.;
}
Direction::Right if room + 1 < self.ha.config.rooms.len() => {
self.ha.room = room + 1;
self.focus(Target::HaRoom(room + 1), controls);
self.scroll = 0.;
}
Direction::Up => self.focus(Target::Nav(2), controls),
Direction::Down => {
if let Some(weather) = &self.ha.config.weather
&& self.ha.entities.contains_key(&weather.entity_id)
{
self.focus(Target::HaWeather, controls);
} else if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
if self.focus == Target::HaWeather {
match direction {
Direction::Up => self.focus(Target::HaRoom(self.ha.room), controls),
Direction::Down => {
if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
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 => {
self.focus(Target::HaCard(room, index - 1), controls);
}
(_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
self.focus(Target::HaCard(room, index + 1), controls);
}
(_, Direction::Up) => {
if index >= cols {
self.focus(Target::HaCard(room, index - cols), controls);
} else if self.ha.config.weather.is_some() {
self.focus(Target::HaWeather, controls);
} else {
self.focus(Target::HaRoom(self.ha.room), controls);
}
}
(_, Direction::Down) => {
let next = index + cols;
if next < room_cfg.cards.len() {
self.focus(Target::HaCard(room, next), controls);
}
}
_ => {}
}
return vec![];
}
}
let setting_rows = match self.subpage {
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
@@ -982,6 +1224,80 @@ impl State {
vec![]
}
fn ha_card_columns(&self) -> usize {
3
}
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("brightness"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
fn ha_entity_target_temp(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(20.) as f32
}
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let vac = match &self.ha.config.vacuum {
Some(v) => v,
None => return vec![],
};
let room_count = vac.rooms.len();
let cols = 3usize;
match &self.focus {
Target::HaVacuumRoom(index) => {
let index = *index;
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaVacuumRoom(index - 1), controls);
}
Direction::Right if index % cols + 1 < cols && index + 1 < room_count => {
self.focus(Target::HaVacuumRoom(index + 1), controls);
}
Direction::Up if index >= cols => {
self.focus(Target::HaVacuumRoom(index - cols), controls);
}
Direction::Up => {
self.focus(Target::HaVacuumClose, controls);
}
Direction::Down => {
let next = index + cols;
if next < room_count {
self.focus(Target::HaVacuumRoom(next), controls);
} else {
self.focus(Target::HaVacuumMode, controls);
}
}
_ => {}
}
}
Target::HaVacuumMode => match direction {
Direction::Up => {
let last_row_start = (room_count.saturating_sub(1)) / cols * cols;
let idx = (last_row_start).min(room_count.saturating_sub(1));
self.focus(Target::HaVacuumRoom(idx), controls);
}
Direction::Down => self.focus(Target::HaVacuumStart, controls),
_ => {}
},
Target::HaVacuumStart => match direction {
Direction::Up => self.focus(Target::HaVacuumMode, controls),
_ => {}
},
Target::HaVacuumClose => match direction {
Direction::Down => self.focus(Target::HaVacuumRoom(0), controls),
_ => {}
},
_ => {}
}
vec![]
}
pub fn options(&mut self) {
if self.overlay.is_some() || self.moving.is_some() {
return;
@@ -1330,6 +1646,82 @@ impl State {
return vec![Effect::Save];
}
}
Target::HaRoom(index) => {
self.ha.room = index;
self.scroll = 0.;
if !self.ha.config.rooms[index].cards.is_empty() {
self.focus = Target::HaCard(index, 0);
}
}
Target::HaCard(room, index) => {
let card = &self.ha.config.rooms[room].cards[index];
match card.card_type {
HaCardType::Toggle => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
HaCardType::Action => {
return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))];
}
HaCardType::Vacuum => {
if let Some(vac) = &self.ha.config.vacuum {
self.ha.vacuum_open = true;
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
self.ha.vacuum_mode = 0;
self.focus = Target::HaVacuumRoom(0);
}
}
_ => {}
}
}
Target::HaVacuumRoom(index) => {
if index < self.ha.vacuum_selected.len() {
self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index];
}
}
Target::HaVacuumMode => {
if let Some(vac) = &self.ha.config.vacuum {
if let Some(entity) = self.ha.entities.get(&vac.cleaning_mode_entity) {
if let Some(options) = entity.attributes.get("options").and_then(|o| o.as_array()) {
self.ha.vacuum_mode = (self.ha.vacuum_mode + 1) % options.len().max(1);
}
}
}
}
Target::HaVacuumStart => {
if let Some(vac) = &self.ha.config.vacuum {
let segments: Vec<u32> = vac.rooms.iter()
.enumerate()
.filter(|(i, _)| self.ha.vacuum_selected.get(*i).copied().unwrap_or(false))
.map(|(_, r)| r.segment_id)
.collect();
if !segments.is_empty() {
let mode = self.ha.entities.get(&vac.cleaning_mode_entity)
.and_then(|e| e.attributes.get("options"))
.and_then(|o| o.as_array())
.and_then(|opts| opts.get(self.ha.vacuum_mode))
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![Effect::Ha(HaEffect::VacuumClean {
entity_id: vac.entity_id.clone(),
segments,
mode,
})];
}
}
}
Target::HaVacuumClose => {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
}
Target::HaVacuumOpen | Target::HaWeather => {}
Target::Sidebar(index) => {
self.section = index;
self.scroll = 0.;
@@ -1560,6 +1952,19 @@ impl State {
self.scroll = 0.;
return vec![];
}
if self.page == Page::Ha && self.ha.vacuum_open {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![];
}
if self.page == Page::Ha && matches!(self.focus, Target::HaCard(..)) {
self.focus = Target::HaRoom(self.ha.room);
self.scroll = 0.;
return vec![];
}
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
self.query.clear();
self.focus = Target::Nav(1);
+666
View File
@@ -1608,7 +1608,13 @@ fn location_settings(out: &mut Scene, state: &State, assets: &Assets) {
}
fn ha(out: &mut Scene, state: &State, assets: &Assets) {
use crate::state::HaCardType;
let language = state.preferences.language;
let config = &state.ha.config;
let s = state.scroll;
if config.rooms.is_empty() {
let rect = Rect::new(84., 164.8, 1752., 780.);
empty(
out,
@@ -1622,6 +1628,666 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
action: None,
},
);
return;
}
// ── Room tabs (horizontal pill strip) ────────────────────────
let mut tab_x = 84.;
for (index, room) in config.rooms.iter().enumerate() {
let width = (assets.width(&room.name, text(16., 500, 0.)) + 36.).max(88.);
let rect = Rect::new(tab_x, 164.8, width, 42.8);
let focused = state.focus == Target::HaRoom(index);
let selected = state.ha.room == index;
out.pill(rect, focused, selected);
let color = if focused {
c(0x2c2c2c, 1.)
} else if selected {
WHITE
} else {
c(0xeeeeee, 0.55)
};
out.text(
&room.name,
Rect::new(rect.x + 18., rect.y + 12., rect.w - 36., 20.),
text(16., 500, 0.),
color,
true,
);
out.control(Target::HaRoom(index), rect, false);
tab_x += width + 8.;
}
// ── Connection indicator ─────────────────────────────────────
if !state.ha.connected {
let dot = Rect::new(tab_x + 12., 178., 12., 12.);
out.shape(dot, 6., c(0xff6b6b, 0.8));
}
let room = &config.rooms[state.ha.room];
let mut y = 230. - s;
// ── Weather hero card (if configured) ────────────────────────
if let Some(weather_cfg) = &config.weather {
if let Some(entity) = state.ha.entities.get(&weather_cfg.entity_id) {
let rect = Rect::new(84., y, 1752., 100.);
ha_weather_card(out, state, assets, rect, entity);
out.control(Target::HaWeather, rect, true);
y += 116.;
}
}
// ── Card grid ────────────────────────────────────────────────
let cols = 3usize;
let card_w = 564.;
let card_gap = 16.;
let card_h = 88.;
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 focused = state.focus == Target::HaCard(state.ha.room, index);
let entity = state.ha.entities.get(&card.entity_id);
match card.card_type {
HaCardType::Toggle => {
ha_toggle_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Range => {
ha_range_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Climate => {
ha_climate_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Action => {
ha_action_card(out, state, assets, rect, card, focused);
}
HaCardType::Vacuum => {
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
}
}
out.control(Target::HaCard(state.ha.room, index), rect, true);
}
// ── Vacuum modal (overlays everything) ───────────────────────
if state.ha.vacuum_open {
if let Some(vac_cfg) = &config.vacuum {
ha_vacuum_modal(out, state, assets, vac_cfg);
}
}
}
// ── Glass card base ──────────────────────────────────────────────
fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) {
out.shadow(
rect,
22.,
if focused { 28. } else { 6. },
if focused { 8. } else { 2. },
if focused { 0.28 } else { 0.06 },
);
out.shape(rect, 22., c(0xffffff, if focused { 0.16 } else { 0.12 }));
out.border(rect, 22., 1., c(0xffffff, if focused { 0.28 } else { 0.15 }));
if focused {
out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.12));
}
}
fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'static str {
use crate::state::HaCardType;
match card_type {
HaCardType::Toggle => {
if entity_id.starts_with("light.") {
"light"
} else if entity_id.starts_with("cover.") {
"curtain"
} else {
"switch"
}
}
HaCardType::Range => "light",
HaCardType::Climate => "thermometer",
HaCardType::Action => "scene",
HaCardType::Vacuum => "vacuum",
}
}
fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color {
match entity.map(|e| e.state.as_str()) {
Some("on") => c(0x7EB8DA, 1.),
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
Some("cool") => c(0x60b0f0, 1.),
_ => c(0xbbbbbb, 0.5),
}
}
// ── Toggle card ──────────────────────────────────────────────────
fn ha_toggle_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 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");
out.icon(
assets,
icon_name,
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
if is_on { accent } else { c(0xbbbbbb, 0.5) },
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
let status = entity.map_or("unavailable", |e| e.state.as_str());
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
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) ─────────────────
fn ha_range_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 is_on = entity.is_some_and(|e| e.state == "on");
let brightness = entity
.and_then(|e| e.attributes.get("brightness"))
.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) };
out.icon(
assets,
"light",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
text(20., 500, -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
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(),
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));
}
}
// ── Climate card ─────────────────────────────────────────────────
fn ha_climate_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 current_temp = entity
.and_then(|e| e.attributes.get("current_temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let target_temp = entity
.and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
out.icon(
assets,
"thermometer",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
out.text(
hvac_mode,
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
false,
);
// Current temperature
let current_str = format!("{:.1}°", current_temp);
out.text(
&current_str,
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
text(24., 600, -0.03).right(),
WHITE,
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));
}
// 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);
}
}
}
fn ha_sparkline(out: &mut Scene, rect: Rect, points: &[(f64, f64)], color: Color) {
if points.is_empty() { 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;
out.shape(
Rect::new(x, y, step.max(2.), bar_h),
0.,
[color[0], color[1], color[2], 0.35],
);
}
}
// ── Action card (scenes, scripts) ────────────────────────────────
fn ha_action_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
focused: bool,
) {
ha_glass(out, rect, focused);
let accent = c(0xc9a0dc, 1.);
out.icon(
assets,
"scene",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
text(20., 500, -0.02),
WHITE,
false,
);
if focused {
out.text(
"OK to activate",
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
text(12., 400, 0.),
c(0xe8e8e8, 0.40),
false,
);
}
}
// ── Vacuum card (summary; opens modal) ───────────────────────────
fn ha_vacuum_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 status = entity.map_or("unavailable", |e| e.state.as_str());
let accent = match status {
"cleaning" => c(0x7EB8DA, 1.),
"returning" => c(0xf0a060, 1.),
_ => c(0xbbbbbb, 0.5),
};
out.icon(
assets,
"vacuum",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
false,
);
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),
false,
);
}
}
// ── Weather hero card ────────────────────────────────────────────
fn ha_weather_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
entity: &crate::state::HaEntity,
) {
let focused = _state.focus == Target::HaWeather;
ha_glass(out, rect, focused);
let condition = entity.state.as_str();
let temp = entity
.attributes
.get("temperature")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let humidity = entity
.attributes
.get("humidity")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let wind = entity
.attributes
.get("wind_speed")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let weather_icon = match condition {
"sunny" | "clear-night" => "weather-sun",
"cloudy" | "fog" => "weather-cloud",
"partlycloudy" => "weather-partly-cloudy",
"rainy" | "pouring" => "weather-rain",
"snowy" => "weather-snow",
"lightning" | "lightning-rainy" => "weather-storm",
"hail" => "weather-drizzle",
_ => "weather-sun",
};
out.icon(
assets,
weather_icon,
Rect::new(rect.x + 24., rect.y + 22., 56., 56.),
c(0xf0d080, 1.),
);
out.text(
&format!("{:.0}°", temp),
Rect::new(rect.x + 100., rect.y + 18., 120., 40.),
text(36., 600, -0.04),
WHITE,
false,
);
out.text(
condition,
Rect::new(rect.x + 100., rect.y + 62., 200., 18.),
text(14.4, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
out.icon(
assets,
"humidity",
Rect::new(rect.x + 340., rect.y + 34., 20., 20.),
c(0x60b0f0, 0.7),
);
out.text(
&format!("{:.0}%", humidity),
Rect::new(rect.x + 368., rect.y + 36., 80., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
out.text(
&format!("{:.0} km/h", wind),
Rect::new(rect.x + 480., rect.y + 36., 120., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
}
// ── Vacuum modal ─────────────────────────────────────────────────
fn ha_vacuum_modal(
out: &mut Scene,
state: &State,
assets: &Assets,
vac: &crate::state::HaVacuumConfig,
) {
let overlay_rect = Rect::new(0., 0., 1920., 1080.);
out.shape(overlay_rect, 0., c(0x080b12, 0.75));
out.modal = Some(overlay_rect);
let modal = Rect::new(320., 120., 1280., 840.);
ha_glass(out, modal, false);
// Title + close
out.text(
"Room Cleaning",
Rect::new(modal.x + 40., modal.y + 28., 400., 32.),
text(28., 600, -0.03),
WHITE,
false,
);
let close_rect = Rect::new(modal.x + modal.w - 72., modal.y + 24., 40., 40.);
out.shape(close_rect, 20., c(0xffffff, 0.08));
out.icon(assets, "close", close_rect.inset(10.), c(0xeeeeee, 0.7));
let close_focused = state.focus == Target::HaVacuumClose;
if close_focused {
out.border(close_rect.inset(-3.), 23., 2., WHITE);
}
out.control(Target::HaVacuumClose, close_rect, false);
// Room grid
let cols = 3usize;
let room_w = 380.;
let room_h = 72.;
let gap = 12.;
let grid_x = modal.x + 40.;
let grid_y = modal.y + 88.;
for (index, room) in vac.rooms.iter().enumerate() {
let col = index % cols;
let row = index / cols;
let rx = grid_x + col as f32 * (room_w + gap);
let ry = grid_y + row as f32 * (room_h + gap);
let rect = Rect::new(rx, ry, room_w, room_h);
let selected = state.ha.vacuum_selected.get(index).copied().unwrap_or(false);
let focused = state.focus == Target::HaVacuumRoom(index);
out.shape(
rect,
16.,
if selected {
c(0x7EB8DA, 0.20)
} else {
c(0xffffff, 0.06)
},
);
out.border(
rect,
16.,
if selected { 2. } else { 1. },
if selected {
c(0x7EB8DA, 0.60)
} else {
c(0xffffff, 0.10)
},
);
if focused {
out.border(rect.inset(-4.), 20., 2., WHITE);
}
if selected {
out.icon(
assets,
"check",
Rect::new(rect.x + 16., rect.y + 20., 32., 32.),
c(0x7EB8DA, 1.),
);
}
out.text(
&room.name,
Rect::new(
rect.x + if selected { 56. } else { 20. },
rect.y + 24.,
rect.w - 76.,
24.,
),
text(18., 500, -0.02),
WHITE,
false,
);
out.control(Target::HaVacuumRoom(index), rect, false);
}
// Cleaning mode selector
let mode_y = grid_y + ((vac.rooms.len() + cols - 1) / cols) as f32 * (room_h + gap) + 16.;
let mode_rect = Rect::new(grid_x, mode_y, 600., 56.);
let mode_focused = state.focus == Target::HaVacuumMode;
out.shape(mode_rect, 14., c(0xffffff, 0.06));
out.border(mode_rect, 14., 1., c(0xffffff, 0.10));
if mode_focused {
out.border(mode_rect.inset(-4.), 18., 2., WHITE);
}
let mode_label = state.ha.entities.get(&vac.cleaning_mode_entity)
.and_then(|e| e.attributes.get("options"))
.and_then(|o| o.as_array())
.and_then(|opts| opts.get(state.ha.vacuum_mode))
.and_then(|v| v.as_str())
.unwrap_or("default");
out.text(
&format!("Mode: {}", mode_label),
Rect::new(mode_rect.x + 20., mode_rect.y + 16., 540., 24.),
text(18., 500, -0.02),
WHITE,
false,
);
out.control(Target::HaVacuumMode, mode_rect, false);
// Start button
let start_y = mode_y + 72.;
let selected_count = state.ha.vacuum_selected.iter().filter(|&&s| s).count();
let start_rect = Rect::new(grid_x, start_y, 600., 56.);
let start_focused = state.focus == Target::HaVacuumStart;
let can_start = selected_count > 0;
out.shape(
start_rect,
14.,
if can_start {
c(0x7EB8DA, 0.25)
} else {
c(0xffffff, 0.04)
},
);
out.border(
start_rect,
14.,
1.,
if can_start {
c(0x7EB8DA, 0.50)
} else {
c(0xffffff, 0.06)
},
);
if start_focused && can_start {
out.border(start_rect.inset(-4.), 18., 2., WHITE);
}
out.text(
&format!(
"Start Cleaning{}",
if selected_count > 0 {
format!(" ({} rooms)", selected_count)
} else {
String::new()
}
),
Rect::new(start_rect.x + 20., start_rect.y + 16., 540., 24.),
text(18., 600, -0.02),
if can_start { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
out.control(Target::HaVacuumStart, start_rect, false);
}
fn settings(out: &mut Scene, state: &State, assets: &Assets) {