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
+1192
-10
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,
|
platform: &Platform,
|
||||||
preferences: &std::path::Path,
|
preferences: &std::path::Path,
|
||||||
weather: &weather_service::Service,
|
weather: &weather_service::Service,
|
||||||
|
ha: Option<&ha_client::HaService>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
match effect {
|
match effect {
|
||||||
@@ -225,11 +226,110 @@ fn effects(
|
|||||||
}
|
}
|
||||||
Effect::Quit => return false,
|
Effect::Quit => return false,
|
||||||
Effect::Weather(command) => weather.send(command),
|
Effect::Weather(command) => weather.send(command),
|
||||||
|
Effect::Ha(ha_effect) => {
|
||||||
|
if let Some(service) = ha {
|
||||||
|
ha_dispatch(service, ha_effect);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
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(
|
fn script(
|
||||||
action: &str,
|
action: &str,
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
@@ -524,6 +624,7 @@ fn run() -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
|
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) {
|
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 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();
|
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() {
|
if !url.is_empty() && !token.is_empty() {
|
||||||
@@ -555,6 +656,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
platform.sync_splash(&state.preferences.background);
|
platform.sync_splash(&state.preferences.background);
|
||||||
startup_stage("preferences and workers");
|
startup_stage("preferences and workers");
|
||||||
@@ -965,6 +1067,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
if press
|
if press
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1008,6 +1111,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1133,7 @@ fn run() -> Result<(), String> {
|
|||||||
&platform,
|
&platform,
|
||||||
&preferences_path,
|
&preferences_path,
|
||||||
&weather_service,
|
&weather_service,
|
||||||
|
ha_service.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let keyboard = state.text_input_active();
|
let keyboard = state.text_input_active();
|
||||||
|
|||||||
+406
-1
@@ -62,6 +62,14 @@ pub enum Target {
|
|||||||
WeatherSearch,
|
WeatherSearch,
|
||||||
WeatherClear,
|
WeatherClear,
|
||||||
WeatherResult(usize),
|
WeatherResult(usize),
|
||||||
|
HaRoom(usize),
|
||||||
|
HaCard(usize, usize),
|
||||||
|
HaWeather,
|
||||||
|
HaVacuumOpen,
|
||||||
|
HaVacuumRoom(usize),
|
||||||
|
HaVacuumMode,
|
||||||
|
HaVacuumStart,
|
||||||
|
HaVacuumClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
@@ -139,6 +147,20 @@ pub enum Effect {
|
|||||||
Launch(String),
|
Launch(String),
|
||||||
Quit,
|
Quit,
|
||||||
Weather(weather::Command),
|
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] = [
|
pub const KEYS: [&str; 40] = [
|
||||||
@@ -147,11 +169,64 @@ pub const KEYS: [&str; 40] = [
|
|||||||
"Delete", "Clear", "Done",
|
"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)]
|
#[derive(Default)]
|
||||||
pub struct HaState {
|
pub struct HaState {
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub entities: std::collections::HashMap<String, HaEntity>,
|
pub entities: std::collections::HashMap<String, HaEntity>,
|
||||||
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -160,6 +235,74 @@ pub struct HaEntity {
|
|||||||
pub attributes: serde_json::Value,
|
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 struct State {
|
||||||
pub page: Page,
|
pub page: Page,
|
||||||
pub focus: Target,
|
pub focus: Target,
|
||||||
@@ -410,7 +553,13 @@ impl State {
|
|||||||
.first()
|
.first()
|
||||||
.map(|app| Target::App(app.id.clone()))
|
.map(|app| Target::App(app.id.clone()))
|
||||||
.unwrap_or(Target::Filter(1)),
|
.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),
|
Page::Settings => Target::Sidebar(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,6 +785,99 @@ impl State {
|
|||||||
return vec![];
|
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 {
|
let setting_rows = match self.subpage {
|
||||||
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
|
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
|
||||||
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
|
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
|
||||||
@@ -982,6 +1224,80 @@ impl State {
|
|||||||
vec![]
|
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) {
|
pub fn options(&mut self) {
|
||||||
if self.overlay.is_some() || self.moving.is_some() {
|
if self.overlay.is_some() || self.moving.is_some() {
|
||||||
return;
|
return;
|
||||||
@@ -1330,6 +1646,82 @@ impl State {
|
|||||||
return vec![Effect::Save];
|
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) => {
|
Target::Sidebar(index) => {
|
||||||
self.section = index;
|
self.section = index;
|
||||||
self.scroll = 0.;
|
self.scroll = 0.;
|
||||||
@@ -1560,6 +1952,19 @@ impl State {
|
|||||||
self.scroll = 0.;
|
self.scroll = 0.;
|
||||||
return vec![];
|
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) {
|
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
|
||||||
self.query.clear();
|
self.query.clear();
|
||||||
self.focus = Target::Nav(1);
|
self.focus = Target::Nav(1);
|
||||||
|
|||||||
+675
-9
@@ -1608,20 +1608,686 @@ fn location_settings(out: &mut Scene, state: &State, assets: &Assets) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ha(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 language = state.preferences.language;
|
||||||
let rect = Rect::new(84., 164.8, 1752., 780.);
|
let config = &state.ha.config;
|
||||||
empty(
|
let s = state.scroll;
|
||||||
out,
|
|
||||||
state,
|
if config.rooms.is_empty() {
|
||||||
|
let rect = Rect::new(84., 164.8, 1752., 780.);
|
||||||
|
empty(
|
||||||
|
out,
|
||||||
|
state,
|
||||||
|
assets,
|
||||||
|
EmptyState {
|
||||||
|
rect,
|
||||||
|
icon: "settings",
|
||||||
|
title: language.t("ha"),
|
||||||
|
hint: "Home Assistant",
|
||||||
|
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,
|
assets,
|
||||||
EmptyState {
|
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,
|
rect,
|
||||||
icon: "settings",
|
16.,
|
||||||
title: language.t("ha"),
|
if selected {
|
||||||
hint: "Home Assistant",
|
c(0x7EB8DA, 0.20)
|
||||||
action: None,
|
} 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) {
|
fn settings(out: &mut Scene, state: &State, assets: &Assets) {
|
||||||
|
|||||||
Reference in new issue
Block a user