From fcc7a41f02ef20cd32b9b4813201d0236c3d0929 Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Sat, 24 Oct 2026 15:41:17 +0300 Subject: [PATCH] Add slider modal for Range, Climate, and Cover cards OK press on Range/Climate/Curtain/Blinds cards now opens a modal overlay with slider controls instead of direct value adjustment. Scroll wheel and D-pad left/right adjust values within the modal. RGB lights with color_temp attribute get a second slider for color temperature (mireds). Adds SetColorTemp HA effect for the light service call. Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 20 ++++++- src/state.rs | 140 ++++++++++++++++++++++++++++++++++++++++++--- src/view.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 51cae28..6590256 100644 --- a/src/main.rs +++ b/src/main.rs @@ -372,6 +372,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { data: serde_json::json!({ "position": position }), }); } + HaEffect::SetColorTemp(entity_id, color_temp) => { + 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!({ "color_temp": color_temp }), + }); + } } } @@ -1072,15 +1081,20 @@ fn run() -> Result<(), String> { mouse_y, .. } if state.overlay.is_none() => { - // HA card scroll: adjust range/climate/media/cover values - let ha_scroll = state.page == state::Page::Ha + // HA card/slider scroll: adjust range/climate/media/cover values + let ha_slider_scroll = state.page == state::Page::Ha + && state.ha.slider_open + && matches!(&state.focus, state::Target::HaSliderBrightness | state::Target::HaSliderColorTemp) + && y != 0; + let ha_scroll = !ha_slider_scroll + && state.page == state::Page::Ha && matches!(&state.focus, state::Target::HaCard(room, index) if matches!( state.ha.config.rooms[*room].cards[*index].card_type, state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media | state::HaCardType::Curtain | state::HaCardType::Blinds )) && y != 0; - if ha_scroll { + if ha_slider_scroll || ha_scroll { pending = state.scroll_ha(y); } else { let over_favorites = state.page == state::Page::Home diff --git a/src/state.rs b/src/state.rs index f8db35e..643cbe3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -70,6 +70,9 @@ pub enum Target { HaVacuumMode, HaVacuumStart, HaVacuumClose, + HaSliderBrightness, + HaSliderColorTemp, + HaSliderClose, } #[derive(Clone, Copy, Debug, Default)] @@ -166,6 +169,7 @@ pub enum HaEffect { MediaPrev(String), SetVolume(String, f32), SetCoverPosition(String, u8), + SetColorTemp(String, u32), } pub const KEYS: [&str; 40] = [ @@ -174,8 +178,9 @@ pub const KEYS: [&str; 40] = [ "Delete", "Clear", "Done", ]; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub enum HaCardType { + #[default] Toggle, Range, Climate, @@ -235,6 +240,9 @@ pub struct HaState { pub vacuum_selected: Vec, pub vacuum_mode: usize, pub vacuum_open: bool, + pub slider_open: bool, + pub slider_entity: String, + pub slider_card_type: HaCardType, } #[derive(Clone, Debug)] @@ -801,6 +809,9 @@ impl State { if self.ha.vacuum_open { return self.ha_vacuum_direction(direction, controls); } + if self.ha.slider_open { + return self.ha_slider_direction(direction, controls); + } if let Target::HaRoom(room) = self.focus { match direction { Direction::Left if room > 0 => { @@ -1220,35 +1231,93 @@ impl State { 4 } - fn ha_entity_brightness(&self, entity_id: &str) -> f32 { + pub 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 { + pub 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_entity_volume(&self, entity_id: &str) -> f32 { + pub fn ha_entity_volume(&self, entity_id: &str) -> f32 { self.ha.entities.get(entity_id) .and_then(|e| e.attributes.get("volume_level")) .and_then(|v| v.as_f64()) .unwrap_or(0.) as f32 } - fn ha_entity_position(&self, entity_id: &str) -> f32 { + pub fn ha_entity_position(&self, entity_id: &str) -> f32 { self.ha.entities.get(entity_id) .and_then(|e| e.attributes.get("current_position")) .and_then(|v| v.as_f64()) .unwrap_or(0.) as f32 } + pub fn ha_entity_color_temp(&self, entity_id: &str) -> u32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("color_temp")) + .and_then(|v| v.as_u64()) + .unwrap_or(300) as u32 + } + + pub fn ha_entity_min_mireds(&self, entity_id: &str) -> u32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("min_mireds")) + .and_then(|v| v.as_u64()) + .unwrap_or(153) as u32 + } + + pub fn ha_entity_max_mireds(&self, entity_id: &str) -> u32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("max_mireds")) + .and_then(|v| v.as_u64()) + .unwrap_or(500) as u32 + } + pub fn scroll_ha(&mut self, delta: i32) -> Vec { + // Slider modal scroll — adjust the focused slider + if self.ha.slider_open { + let eid = self.ha.slider_entity.clone(); + match self.focus { + Target::HaSliderBrightness => { + match self.ha.slider_card_type { + HaCardType::Range => { + let brightness = self.ha_entity_brightness(&eid); + let new = (brightness + delta as f32 * 25.5).clamp(0., 255.); + return vec![Effect::Ha(HaEffect::SetBrightness(eid, new))]; + } + HaCardType::Climate => { + let temp = self.ha_entity_target_temp(&eid); + let new = temp + delta as f32 * 0.5; + return vec![Effect::Ha(HaEffect::SetClimateTemp(eid, new))]; + } + HaCardType::Curtain | HaCardType::Blinds => { + let pos = self.ha_entity_position(&eid); + let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8; + return vec![Effect::Ha(HaEffect::SetCoverPosition(eid, new))]; + } + _ => {} + } + } + Target::HaSliderColorTemp => { + let ct = self.ha_entity_color_temp(&eid); + let min = self.ha_entity_min_mireds(&eid); + let max = self.ha_entity_max_mireds(&eid); + let step = ((max - min) / 10).max(1); + let new = (ct as i64 + delta as i64 * step as i64).clamp(min as i64, max as i64) as u32; + return vec![Effect::Ha(HaEffect::SetColorTemp(eid, new))]; + } + _ => {} + } + return vec![]; + } + if let Target::HaCard(room, index) = &self.focus { let card = &self.ha.config.rooms[*room].cards[*index]; match card.card_type { @@ -1334,6 +1403,41 @@ impl State { vec![] } + fn ha_slider_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec { + let has_color_temp = matches!(self.ha.slider_card_type, HaCardType::Range) + && self.ha.entities.get(&self.ha.slider_entity) + .and_then(|e| e.attributes.get("color_temp")) + .is_some(); + + match &self.focus { + Target::HaSliderBrightness => match direction { + Direction::Up => self.focus(Target::HaSliderClose, controls), + Direction::Down if has_color_temp => { + self.focus(Target::HaSliderColorTemp, controls); + } + Direction::Left | Direction::Right => { + let delta = if matches!(direction, Direction::Left) { -1 } else { 1 }; + return self.scroll_ha(delta); + } + _ => {} + }, + Target::HaSliderColorTemp => match direction { + Direction::Up => self.focus(Target::HaSliderBrightness, controls), + Direction::Left | Direction::Right => { + let delta = if matches!(direction, Direction::Left) { -1 } else { 1 }; + return self.scroll_ha(delta); + } + _ => {} + }, + Target::HaSliderClose => match direction { + Direction::Down => self.focus(Target::HaSliderBrightness, controls), + _ => {} + }, + _ => {} + } + vec![] + } + pub fn options(&mut self) { if self.overlay.is_some() || self.moving.is_some() { return; @@ -1709,12 +1813,24 @@ impl State { HaCardType::Media => { return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; } - HaCardType::Curtain | HaCardType::Blinds => { - return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))]; + HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => { + self.ha.slider_open = true; + self.ha.slider_entity = card.entity_id.clone(); + self.ha.slider_card_type = card.card_type.clone(); + self.focus = Target::HaSliderBrightness; } - _ => {} } } + Target::HaSliderBrightness | Target::HaSliderColorTemp => { + // scroll handles these — OK does nothing on slider tracks + } + Target::HaSliderClose => { + self.ha.slider_open = false; + self.focus = Target::HaCard(self.ha.room, + self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| c.entity_id == self.ha.slider_entity) + .unwrap_or(0)); + } Target::HaVacuumRoom(index) => { if index < self.ha.vacuum_selected.len() { self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index]; @@ -1994,6 +2110,14 @@ impl State { self.scroll = 0.; return vec![]; } + if self.page == Page::Ha && self.ha.slider_open { + self.ha.slider_open = false; + self.focus = Target::HaCard(self.ha.room, + self.ha.config.rooms[self.ha.room].cards.iter() + .position(|c| c.entity_id == self.ha.slider_entity) + .unwrap_or(0)); + return vec![]; + } if self.page == Page::Ha && self.ha.vacuum_open { self.ha.vacuum_open = false; self.focus = Target::HaCard(self.ha.room, diff --git a/src/view.rs b/src/view.rs index 56ac46b..bac2415 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1754,6 +1754,11 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { ha_vacuum_modal(out, state, assets, vac_cfg); } } + + // ── Slider modal (overlays everything) ────────────────────── + if state.ha.slider_open { + ha_slider_modal(out, state, assets); + } } // ── Glass card base ────────────────────────────────────────────── @@ -2836,6 +2841,160 @@ fn ha_vacuum_modal( out.control(Target::HaVacuumStart, start_rect, false); } +fn ha_slider_modal(out: &mut Scene, state: &State, assets: &Assets) { + use crate::state::HaCardType; + let entity_id = &state.ha.slider_entity; + let entity = state.ha.entities.get(entity_id); + let card_type = &state.ha.slider_card_type; + + let overlay_rect = Rect::new(0., 0., 1920., 1080.); + out.shape(overlay_rect, 0., c(0x080b12, 0.75)); + out.modal = Some(overlay_rect); + + let has_color_temp = matches!(card_type, HaCardType::Range) + && entity.and_then(|e| e.attributes.get("color_temp")).is_some(); + let modal_h = if has_color_temp { 400. } else { 280. }; + let modal = Rect::new(480., (1080. - modal_h) / 2., 960., modal_h); + ha_glass(out, modal, false); + + // Title + let title = match card_type { + HaCardType::Range => "Light", + HaCardType::Climate => "Climate", + HaCardType::Curtain => "Curtain", + HaCardType::Blinds => "Blinds", + _ => "Control", + }; + // Find card name from config + let card_name = state.ha.config.rooms.iter() + .flat_map(|r| r.cards.iter()) + .find(|c| c.entity_id == *entity_id) + .map(|c| c.name.as_str()) + .unwrap_or(title); + out.text( + card_name, + Rect::new(modal.x + 40., modal.y + 28., 600., 32.), + text(28., 600, -0.03), + WHITE, + false, + ); + + // Close button + 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::HaSliderClose; + if close_focused { + out.border(close_rect.inset(-3.), 23., 2., WHITE); + } + out.control(Target::HaSliderClose, close_rect, false); + + // Primary slider (brightness / temperature / position) + let slider_y = modal.y + 88.; + let slider_x = modal.x + 40.; + let slider_w = modal.w - 80.; + let slider_h = 72.; + let slider_rect = Rect::new(slider_x, slider_y, slider_w, slider_h); + let primary_focused = state.focus == Target::HaSliderBrightness; + + out.shape(slider_rect, 16., c(0xffffff, 0.06)); + out.border(slider_rect, 16., 1., c(0xffffff, 0.10)); + if primary_focused { + out.border(slider_rect.inset(-4.), 20., 2., WHITE); + } + + // Value bar and label + let (label, fraction) = match card_type { + HaCardType::Range => { + let brightness = state.ha_entity_brightness(entity_id); + let pct = (brightness / 255. * 100.).round() as u8; + (format!("Brightness: {}%", pct), brightness / 255.) + } + HaCardType::Climate => { + let temp = state.ha_entity_target_temp(entity_id); + let min = 16_f32; + let max = 30_f32; + let frac = ((temp - min) / (max - min)).clamp(0., 1.); + (format!("Temperature: {:.1}°C", temp), frac) + } + HaCardType::Curtain | HaCardType::Blinds => { + let pos = state.ha_entity_position(entity_id); + (format!("Position: {}%", pos.round() as u8), pos / 100.) + } + _ => ("Value".to_string(), 0.), + }; + + // Filled bar + let bar_inset = 4.; + let bar_rect = Rect::new( + slider_rect.x + bar_inset, + slider_rect.y + bar_inset, + (slider_rect.w - bar_inset * 2.) * fraction.clamp(0., 1.), + slider_rect.h - bar_inset * 2., + ); + let accent = ha_entity_accent_color(entity); + let bar_color = [accent[0], accent[1], accent[2], 0.35]; + out.shape(bar_rect, 12., bar_color); + + out.text( + &label, + Rect::new(slider_rect.x + 20., slider_rect.y + 24., slider_w - 40., 24.), + text(18., 500, -0.02), + WHITE, + false, + ); + out.control(Target::HaSliderBrightness, slider_rect, false); + + // Color temperature slider (only for Range lights with color_temp) + if has_color_temp { + let ct_y = slider_y + slider_h + 20.; + let ct_rect = Rect::new(slider_x, ct_y, slider_w, slider_h); + let ct_focused = state.focus == Target::HaSliderColorTemp; + + out.shape(ct_rect, 16., c(0xffffff, 0.06)); + out.border(ct_rect, 16., 1., c(0xffffff, 0.10)); + if ct_focused { + out.border(ct_rect.inset(-4.), 20., 2., WHITE); + } + + let ct = state.ha_entity_color_temp(entity_id); + let min_m = state.ha_entity_min_mireds(entity_id); + let max_m = state.ha_entity_max_mireds(entity_id); + let range = (max_m - min_m).max(1) as f32; + let frac = (ct as f32 - min_m as f32) / range; + let kelvin = if ct > 0 { 1_000_000 / ct } else { 4000 }; + + // Warm-to-cool gradient bar + let ct_bar = Rect::new( + ct_rect.x + bar_inset, + ct_rect.y + bar_inset, + (ct_rect.w - bar_inset * 2.) * frac.clamp(0., 1.), + ct_rect.h - bar_inset * 2., + ); + // Warm amber color for the filled portion + out.shape(ct_bar, 12., c(0xffaa44, 0.35)); + + out.text( + &format!("Color Temp: {}K", kelvin), + Rect::new(ct_rect.x + 20., ct_rect.y + 24., slider_w - 40., 24.), + text(18., 500, -0.02), + WHITE, + false, + ); + out.control(Target::HaSliderColorTemp, ct_rect, false); + } + + // Hint text at bottom + let hint_y = modal.y + modal_h - 44.; + out.text( + "Scroll or ◀ ▶ to adjust", + Rect::new(slider_x, hint_y, slider_w, 20.), + text(14., 400, -0.01), + c(0xeeeeee, 0.4), + false, + ); +} + fn settings(out: &mut Scene, state: &State, assets: &Assets) { let language = state.preferences.language; let s = state.scroll;