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:
1 parent
98dd75ab0e
commit
764af371b9
9 files changed
+1183
-1
No files matched your search
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
¤t_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) {
|
||||
|
||||
Reference in new issue
Block a user