Add slider modal for Range, Climate, and Cover cards
CI / Publish release and Homebrew feed (push) Canceled after 0s
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 15:41:17 +03:00
1 parent 4044e4db50
commit fcc7a41f02
3 files changed
+308 -11

No files matched your search

+17 -3
View File
@@ -372,6 +372,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
data: serde_json::json!({ "position": position }), 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, mouse_y,
.. ..
} if state.overlay.is_none() => { } if state.overlay.is_none() => {
// HA card scroll: adjust range/climate/media/cover values // HA card/slider scroll: adjust range/climate/media/cover values
let ha_scroll = state.page == state::Page::Ha 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) && matches!(&state.focus, state::Target::HaCard(room, index)
if matches!( if matches!(
state.ha.config.rooms[*room].cards[*index].card_type, state.ha.config.rooms[*room].cards[*index].card_type,
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media | state::HaCardType::Curtain | state::HaCardType::Blinds state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media | state::HaCardType::Curtain | state::HaCardType::Blinds
)) ))
&& y != 0; && y != 0;
if ha_scroll { if ha_slider_scroll || ha_scroll {
pending = state.scroll_ha(y); pending = state.scroll_ha(y);
} else { } else {
let over_favorites = state.page == state::Page::Home let over_favorites = state.page == state::Page::Home
+132 -8
View File
@@ -70,6 +70,9 @@ pub enum Target {
HaVacuumMode, HaVacuumMode,
HaVacuumStart, HaVacuumStart,
HaVacuumClose, HaVacuumClose,
HaSliderBrightness,
HaSliderColorTemp,
HaSliderClose,
} }
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
@@ -166,6 +169,7 @@ pub enum HaEffect {
MediaPrev(String), MediaPrev(String),
SetVolume(String, f32), SetVolume(String, f32),
SetCoverPosition(String, u8), SetCoverPosition(String, u8),
SetColorTemp(String, u32),
} }
pub const KEYS: [&str; 40] = [ pub const KEYS: [&str; 40] = [
@@ -174,8 +178,9 @@ pub const KEYS: [&str; 40] = [
"Delete", "Clear", "Done", "Delete", "Clear", "Done",
]; ];
#[derive(Clone, Debug)] #[derive(Clone, Debug, Default)]
pub enum HaCardType { pub enum HaCardType {
#[default]
Toggle, Toggle,
Range, Range,
Climate, Climate,
@@ -235,6 +240,9 @@ pub struct HaState {
pub vacuum_selected: Vec<bool>, pub vacuum_selected: Vec<bool>,
pub vacuum_mode: usize, pub vacuum_mode: usize,
pub vacuum_open: bool, pub vacuum_open: bool,
pub slider_open: bool,
pub slider_entity: String,
pub slider_card_type: HaCardType,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -801,6 +809,9 @@ impl State {
if self.ha.vacuum_open { if self.ha.vacuum_open {
return self.ha_vacuum_direction(direction, controls); 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 { if let Target::HaRoom(room) = self.focus {
match direction { match direction {
Direction::Left if room > 0 => { Direction::Left if room > 0 => {
@@ -1220,35 +1231,93 @@ impl State {
4 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) self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("brightness")) .and_then(|e| e.attributes.get("brightness"))
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.) as f32 .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) self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("temperature")) .and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(20.) as f32 .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) self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("volume_level")) .and_then(|e| e.attributes.get("volume_level"))
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.) as f32 .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) self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("current_position")) .and_then(|e| e.attributes.get("current_position"))
.and_then(|v| v.as_f64()) .and_then(|v| v.as_f64())
.unwrap_or(0.) as f32 .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<Effect> { pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
// 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 { if let Target::HaCard(room, index) = &self.focus {
let card = &self.ha.config.rooms[*room].cards[*index]; let card = &self.ha.config.rooms[*room].cards[*index];
match card.card_type { match card.card_type {
@@ -1334,6 +1403,41 @@ impl State {
vec![] vec![]
} }
fn ha_slider_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
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) { 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;
@@ -1709,12 +1813,24 @@ impl State {
HaCardType::Media => { HaCardType::Media => {
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))];
} }
HaCardType::Curtain | HaCardType::Blinds => { HaCardType::Range | HaCardType::Climate | HaCardType::Curtain | HaCardType::Blinds => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))]; 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) => { Target::HaVacuumRoom(index) => {
if index < self.ha.vacuum_selected.len() { if index < self.ha.vacuum_selected.len() {
self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index]; self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index];
@@ -1994,6 +2110,14 @@ impl State {
self.scroll = 0.; self.scroll = 0.;
return vec![]; 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 { if self.page == Page::Ha && self.ha.vacuum_open {
self.ha.vacuum_open = false; self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room, self.focus = Target::HaCard(self.ha.room,
+159
View File
@@ -1754,6 +1754,11 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
ha_vacuum_modal(out, state, assets, vac_cfg); 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 ────────────────────────────────────────────── // ── Glass card base ──────────────────────────────────────────────
@@ -2836,6 +2841,160 @@ fn ha_vacuum_modal(
out.control(Target::HaVacuumStart, start_rect, false); 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) { fn settings(out: &mut Scene, state: &State, assets: &Assets) {
let language = state.preferences.language; let language = state.preferences.language;
let s = state.scroll; let s = state.scroll;