diff --git a/src/app/snapdash.rs b/src/app/snapdash.rs index 8a83908..b8e4e89 100644 --- a/src/app/snapdash.rs +++ b/src/app/snapdash.rs @@ -60,6 +60,7 @@ pub struct Snapdash { pub status: String, pub entity_windows: HashMap, + pub entity_windows_opening: HashSet, pub boot_open_done: bool, pub settings_sensors: Vec, @@ -90,6 +91,7 @@ pub enum Message { OpenEntity(String), OpenReleaseNotes, OpenUrl(String), + OpenWidgetSettings(String), CloseWindow(window::Id), QuitApp, WindowClosed(window::Id), @@ -149,6 +151,12 @@ pub enum Message { WidgetSizeChanged(WidgetSize), WidgetPriorityChanged(String, Priority), + WidgetNameChanged(String, String), + + WidgetVisibilityToggled(String, bool), + WidgetVisibilityTriggerChanged(String, String), + WidgetVisibilityConditionChanged(String, crate::widget_visibility::ConditionKind), + WidgetVisibilityValueChanged(String, String), InstallUpdate, UpdateInstelled(Result), @@ -199,6 +207,7 @@ impl Snapdash { theme_options: vec![ThemeKind::MacLight, ThemeKind::MacDark], windows: HashMap::new(), entity_windows: HashMap::new(), + entity_windows_opening: HashSet::new(), boot_open_done: false, settings_sensors: Vec::new(), selected_widgets: HashSet::new(), @@ -411,11 +420,13 @@ impl Snapdash { } HaEvent::InitialState(states) => { self.apply_initial_states(states); - Task::none() + + // we can evaluate any rules whose triggers we previously didn't know. + self.update_widget_visibility() } HaEvent::StateChanged { new_state } => { self.apply_entity_state(new_state); - Task::none() + self.update_widget_visibility() } HaEvent::AuthFailed(error) => { self.ha.connected = false; @@ -458,6 +469,74 @@ impl Snapdash { fn is_entity_window_open(&self, entity_id: &str) -> bool { self.entity_windows.contains_key(entity_id) + || self.entity_windows_opening.contains(entity_id) + } + + /// Resolved title for a widget: custom override (if any) → HA + /// friendly_name → bare entity_id without the domain prefix. + pub fn display_name(&self, entity_id: &str) -> String { + if let Some(name) = self + .config + .widget_names + .get(entity_id) + .filter(|s| !s.is_empty()) + { + return name.clone(); + } + + if let Some(name) = self + .ha + .entities + .get(entity_id) + .and_then(|s| s.attributes.get("friendly_name").and_then(|v| v.as_str())) + { + return name.to_owned(); + } + + entity_id.split('.').nth(1).unwrap_or(entity_id).to_owned() + } + + /// `true` when a widget should currently be shown. No rule → always + /// visible. Rule → delegate to its evaluator against current HA + /// entities. + pub fn is_widget_visible(&self, entity_id: &str) -> bool { + match self.config.widget_visibility.get(entity_id) { + None => true, + Some(rule) => rule.evaluate(&self.ha.entities), + } + } + + /// Walk every rule-gated widget and emit OpenEntity / CloseWindow + /// tasks for the deltas (newly-visible-but-not-open, or + /// currently-open-but-now-hidden). Widgets WITHOUT a rule are not + /// touched — they're under user control, otherwise closing one + /// manually would have it bounce right back open. + fn update_widget_visibility(&mut self) -> Task { + let mut tasks: Vec> = Vec::new(); + + for (entity_id, rule) in &self.config.widget_visibility { + if !self.config.widgets.contains(entity_id) { + continue; + } + + let visible = rule.evaluate(&self.ha.entities); + + if visible { + if !self.is_entity_window_open(entity_id) { + tracing::debug!(entity = %entity_id, "visibility rule: opening widget"); + tasks.push(Task::done(Message::OpenEntity(entity_id.clone()))); + } + } else if let Some(&id) = self.entity_windows.get(entity_id) { + tracing::debug!(entity = %entity_id, "visibility rule: closing widget"); + tasks.push(Task::done(Message::CloseWindow(id))); + } + } + + if tasks.is_empty() { + Task::none() + } else { + Task::batch(tasks) + } } pub fn update(&mut self, message: Message) -> Task { @@ -978,6 +1057,7 @@ impl Snapdash { } self.entity_windows.insert(entity_id.clone(), id); + self.entity_windows_opening.remove(entity_id); } self.windows.insert(id, WindowState { kind, entity }); @@ -990,6 +1070,7 @@ impl Snapdash { && let WindowKind::Entity { entity_id } = window.kind { self.entity_windows.remove(&entity_id); + self.entity_windows_opening.remove(&entity_id); } if self.windows.is_empty() { @@ -1063,8 +1144,16 @@ impl Snapdash { } Message::OpenEntity(entity_id) => { + // Bulk boot-open: skip widgets currently hidden by their rule. + // At boot HA is not connected, so an gated widget eveluates to "trigger unkonwn" + // and will open later once HA is connected let widgets: Vec = if entity_id.is_empty() { - self.config.widgets.clone() + self.config + .widgets + .iter() + .filter(|id| self.is_widget_visible(id)) + .cloned() + .collect() } else { vec![entity_id] }; @@ -1078,6 +1167,10 @@ impl Snapdash { continue; } + // Reserve slot immediatelly, so another event fired before our + // WindowOpened arrives sees the entity as already-being-opened and skips it. + self.entity_windows_opening.insert(widget.clone()); + let mut win_settings = window_settings( self.config.widget_settings.widget_size.window_size(), false, @@ -1241,6 +1334,20 @@ impl Snapdash { kind: WindowKind::ReleaseNotes, }) } + Message::OpenWidgetSettings(entity_id) => { + let kind = WindowKind::WidgetSettings { entity_id }; + + if let Some(opened) = find_window_id(&self.windows, kind.clone(), None) { + return iced::window::gain_focus::(opened); + } + + let settings = window_settings(iced::Size::new(540.0, 540.0), true); + let (id, task_id) = window::open(settings); + task_id.map(move |_| Message::WindowOpened { + id, + kind: kind.clone(), + }) + } Message::OpenUrl(url) => { if let Err(e) = open::that(&url) { tracing::warn!(url, error = %e, "failed to open URL"); @@ -1280,6 +1387,73 @@ impl Snapdash { ) } + Message::WidgetNameChanged(entity_id, raw) => { + // Empty/whitespace → drop the override so the widget falls + // back to HA's friendly_name (mirrors the "Normal = drop" + // pattern from priority — only deviations are persisted). + + if raw.trim().is_empty() { + self.config.widget_names.remove(&entity_id); + } else { + self.config.widget_names.insert(entity_id, raw); + } + self.save_config() + } + + Message::WidgetVisibilityToggled(entity_id, on) => { + let post_task = if on { + // Sensible default: self-trigger + IsAvailable — + // covers "show this sensor only while it reports + // something real" without forcing the user to pick a + // trigger entity upfront. + self.config.widget_visibility.insert( + entity_id.clone(), + crate::widget_visibility::VisibilityRule { + trigger: entity_id, + condition: crate::widget_visibility::VisibilityCondition::IsAvailable, + }, + ); + self.update_widget_visibility() + } else { + self.config.widget_visibility.remove(&entity_id); + if self.is_entity_window_open(&entity_id) { + Task::none() + } else { + Task::done(Message::OpenEntity(entity_id)) + } + }; + + self.save_config().chain(post_task) + } + + Message::WidgetVisibilityTriggerChanged(entity_id, trigger) => { + if let Some(rule) = self.config.widget_visibility.get_mut(&entity_id) { + rule.trigger = trigger; + } + self.save_config().chain(self.update_widget_visibility()) + } + + Message::WidgetVisibilityConditionChanged(entity_id, kind) => { + if let Some(rule) = self.config.widget_visibility.get_mut(&entity_id) { + let raw = rule + .condition + .raw_value() + .map(String::from) + .unwrap_or_default(); + rule.condition = kind.with_value(raw); + } + self.save_config().chain(self.update_widget_visibility()) + } + + Message::WidgetVisibilityValueChanged(entity_id, raw) => { + if let Some(rule) = self.config.widget_visibility.get_mut(&entity_id) { + let kind = + crate::widget_visibility::ConditionKind::from_condition(&rule.condition); + rule.condition = kind.with_value(raw); + } + self.save_config().chain(self.update_widget_visibility()) + } + Message::PersistWidgetPositions => { let Some(last) = self.last_widget_move_at else { return Task::none(); @@ -1336,6 +1510,7 @@ impl Snapdash { WindowKind::Settings => inner, WindowKind::ReleaseNotes => inner, WindowKind::ThemeGallery => inner, + WindowKind::WidgetSettings { .. } => inner, }; // Platform-specific outer wrapping: diff --git a/src/app/window.rs b/src/app/window.rs index 7535d53..dea00d3 100644 --- a/src/app/window.rs +++ b/src/app/window.rs @@ -71,6 +71,7 @@ pub enum WindowKind { Entity { entity_id: String }, ReleaseNotes, ThemeGallery, + WidgetSettings { entity_id: String }, } #[derive(Debug, Clone)] diff --git a/src/config.rs b/src/config.rs index 7bbc62c..6068d22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,10 @@ pub struct Config { pub widget_positions: HashMap, #[serde(default)] pub widget_priorities: HashMap, + #[serde(default)] + pub widget_names: HashMap, + #[serde(default)] + pub widget_visibility: HashMap, } #[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq)] @@ -59,6 +63,8 @@ impl Default for Config { adaptive: crate::widget_size::Adaptive::default(), show_measurement_info: true, }, + widget_names: HashMap::new(), + widget_visibility: HashMap::new(), } } } diff --git a/src/lib.rs b/src/lib.rs index 503314e..f2fd5ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod theme; pub mod ui; pub mod update; pub mod widget_size; +pub mod widget_visibility; use iced::daemon; diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 14ce7e8..48d69fa 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -31,10 +31,14 @@ pub fn window_content<'a>( app.update.is_available(), app.config.widget_settings, priority, + app.display_name(entity_id), ) } WindowKind::ReleaseNotes => crate::ui::release_notes::view(app, id), WindowKind::ThemeGallery => crate::ui::gallery::view(app, id), + WindowKind::WidgetSettings { entity_id } => { + crate::ui::widget_settings::view(app, id, entity_id) + } } } @@ -70,6 +74,25 @@ pub fn with_gear_overlay<'a>( .padding(10) .into(); + // Per-widget Configure button (top-right). Distinct from the + // app-settings gear (bottom-right) — gear opens app Settings, sliders + // open this widget's settings dialog. The priority dots stay in the + // opposite corner (bottom-left) as a quick-access shortcut for the + // most-frequent tweak. Gear will move to the system tray in a future + // release; sliders + dots will remain on the widget. + let configure_button = iced::widget::button(Icon::Sliders.text(p)) + .padding(0) + .on_press(Message::OpenWidgetSettings(win.entity.entity_id.clone())) + .style(icon_button(p, 1.0)); + + let configure_layer: Element = iced::widget::container(configure_button) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::End) + .align_y(Alignment::Center) + .padding(10) + .into(); + let current = app .config .widget_priorities @@ -85,7 +108,7 @@ pub fn with_gear_overlay<'a>( .align_y(Alignment::End) .padding(10) .into(); - iced::widget::stack![inner, priority_layer, gear_layer].into() + iced::widget::stack![inner, priority_layer, configure_layer, gear_layer].into() } fn priority_selector<'a>(entity_id: &str, current: Priority, p: Palette) -> Element<'a, Message> { @@ -165,5 +188,6 @@ pub fn with_mouse_area<'a>( WindowKind::Settings => ma.into(), WindowKind::ReleaseNotes => ma.into(), WindowKind::ThemeGallery => ma.into(), + WindowKind::WidgetSettings { .. } => ma.into(), } } diff --git a/src/ui/components/sensors.rs b/src/ui/components/sensors.rs index d8c7950..18f9b97 100644 --- a/src/ui/components/sensors.rs +++ b/src/ui/components/sensors.rs @@ -1,8 +1,10 @@ -use iced::widget::{checkbox, column, container, row, text}; +use iced::widget::{button, checkbox, column, container, row, space, text}; use iced::{Alignment, Background, Border, Element, Length, Padding}; use crate::app::Message; use crate::theme::{Palette, metric, text_size}; +use crate::ui::icon::Icon; +use crate::ui::theme::icon_button; pub fn status_dot(p: Palette, ok: bool) -> Element<'static, Message> { let color = if ok { p.success } else { p.danger }; @@ -29,11 +31,21 @@ pub fn active_sensor_section(app: &crate::app::Snapdash, p: Palette) -> Element< let mut col = column![]; for sensor in active_sensors { + let badge = if app.config.widget_visibility.contains_key(&sensor.entity_id) + && !app.is_widget_visible(&sensor.entity_id) + { + Some(hidden_badge(p)) + } else { + None + }; + col = col.push(sensor_row( sensor.entity_id.as_str(), sensor.friendly_name.as_str(), true, metric::PAD.into(), + badge, + Some(Message::OpenWidgetSettings(sensor.entity_id.clone())), p, )); } @@ -59,6 +71,8 @@ pub fn sensors_section(app: &crate::app::Snapdash, p: Palette) -> Element<'_, Me sensor.friendly_name.as_str(), app.selected_widgets.contains(&sensor.entity_id), 8.0.into(), + None, + None, p, )); } @@ -73,43 +87,86 @@ fn sensor_row<'a>( friendly_name: &'a str, selected: bool, padding: Padding, + badge: Option>, + on_configure: Option, p: Palette, ) -> Element<'a, Message> { let id_for_msg = entity_id.to_owned(); - column![ - checkbox(selected) - .label(friendly_name) - .on_toggle(move |_| Message::ToggleWidget(id_for_msg.clone())) - .text_size(text_size::NORMAL) - .style(move |_, _| iced::widget::checkbox::Style { - background: iced::Background::Color(p.bg), - text_color: Some(p.text_primary), - icon_color: p.accent, - border: Border { - color: p.text_primary, - width: 0.5, - radius: 15.0.into() - } - }), - row![ - text(format!(" ({entity_id})")) - .size(text_size::XSMALL) - .wrapping(text::Wrapping::WordOrGlyph) - .style(move |_: &iced::Theme| iced::widget::text::Style { - color: Some(p.text_dim) - }) - .align_x(Alignment::Start), - ] + let cb = checkbox(selected) + .label(friendly_name) + .on_toggle(move |_| Message::ToggleWidget(id_for_msg.clone())) + .text_size(text_size::NORMAL) + .style(move |_, _| iced::widget::checkbox::Style { + background: iced::Background::Color(p.bg), + text_color: Some(p.text_primary), + icon_color: p.accent, + border: Border { + color: p.text_primary, + width: 0.5, + radius: 15.0.into(), + }, + }); + let top_row: Element<'a, Message> = if let Some(msg) = on_configure { + let configure_btn = button(Icon::Sliders.text(p).size(13.0)) + .padding(2) + .on_press(msg) + .style(icon_button(p, 0.5)); + row![cb, space().width(Length::Fill), configure_btn,] + .align_y(Alignment::Start) + .spacing(8) + .into() + } else { + cb.into() + }; + + let id_text: Element<'a, Message> = text(format!(" ({entity_id})")) + .size(text_size::XSMALL) .width(Length::Fill) - .align_y(Alignment::Start) - .padding(Padding { - top: 0.0, - right: 6.0, - bottom: 0.0, - left: 20.0 + .wrapping(text::Wrapping::WordOrGlyph) + .style(move |_: &iced::Theme| iced::widget::text::Style { + color: Some(p.text_dim), }) - ] - .padding(padding) + .align_x(Alignment::Start) + .into(); + + let mut id_row = row![id_text].spacing(6).align_y(Alignment::Start); + + if let Some(b) = badge { + id_row = id_row.push(b); + } + + let id_row = id_row.padding(Padding { + top: 0.0, + right: 6.0, + bottom: 0.0, + left: 20.0, + }); + + column![top_row, id_row].padding(padding).into() +} + +fn hidden_badge<'a>(p: Palette) -> Element<'a, Message> { + container( + text("Hidden by rule") + .size(text_size::XSMALL) + .wrapping(text::Wrapping::None) + .style(move |_: &iced::Theme| iced::widget::text::Style { + color: Some(p.danger), + }), + ) + .padding([2, 8]) + .style(move |_| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color { + a: 0.12, + ..p.danger + })), + border: iced::Border { + radius: 999.0.into(), + width: 1.0, + color: iced::Color { a: 0.4, ..p.danger }, + }, + ..Default::default() + }) .into() } diff --git a/src/ui/entity_window.rs b/src/ui/entity_window.rs index 6f38ac3..d53a577 100644 --- a/src/ui/entity_window.rs +++ b/src/ui/entity_window.rs @@ -7,10 +7,6 @@ use crate::theme::{Palette, metric}; use crate::ui::format::format_entity_value; use crate::widget_size::{Priority, WidgetSize}; -fn pretty_name(entity_id: &str) -> &str { - entity_id.split('.').nth(1).unwrap_or(entity_id) -} - fn format_main_value( state: &EntityWindowState, ) -> (Option, Option, Option) { @@ -58,8 +54,9 @@ pub fn view( update: bool, widget_settings: crate::config::WidgetSettings, priority: Priority, + title: String, ) -> Element<'_, Message> { - let (friendly, main_opt, detail) = format_main_value(state); + let (_friendly, main_opt, detail) = format_main_value(state); let update_button = components::icon_button( crate::ui::icon::Icon::Download, @@ -75,28 +72,13 @@ pub fn view( .interaction(iced::mouse::Interaction::Pointer) .into(); - let mut title_text = if let Some(name) = friendly { - row![ - column![ - text(name) - .size(widget_settings.widget_size.title_font()) - .style(move |_: &iced::Theme| { - iced::widget::text::Style { - color: Some(p.text_secondary), - } - }), - ] - .width(iced::Fill), - ] - } else { - row![ - text(pretty_name(&state.entity_id)) - .size(widget_settings.widget_size.title_font()) - .style(move |_: &iced::Theme| iced::widget::text::Style { - color: Some(p.text_secondary), - }), - ] - }; + let title_widget = text(title) + .size(widget_settings.widget_size.title_font()) + .style(move |_: &iced::Theme| iced::widget::text::Style { + color: Some(p.text_secondary), + }); + + let mut title_text = row![column![title_widget].width(iced::Fill)]; if update { title_text = title_text.push(update_icon) diff --git a/src/ui/icon.rs b/src/ui/icon.rs index f8bb1f5..03378f4 100644 --- a/src/ui/icon.rs +++ b/src/ui/icon.rs @@ -19,6 +19,7 @@ pub enum Icon { Refresh, ExternalLink, FolderOpen, + Sliders, } impl Icon { @@ -36,6 +37,7 @@ impl Icon { Self::Refresh => '\u{e144}', Self::ExternalLink => '\u{e0b9}', Self::FolderOpen => '\u{e247}', + Self::Sliders => '\u{e29a}', } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index da2328e..7e021d6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -9,3 +9,4 @@ pub mod release_notes; pub mod settings; pub mod theme; pub mod update_view; +pub mod widget_settings; diff --git a/src/ui/widget_settings.rs b/src/ui/widget_settings.rs new file mode 100644 index 0000000..caee5ae --- /dev/null +++ b/src/ui/widget_settings.rs @@ -0,0 +1,240 @@ +use iced::widget::{column, container, mouse_area, row, space}; +use iced::{Alignment, Element, Length, window}; + +use crate::app::{Message, Snapdash}; +use crate::theme::{metric, text_size}; +use crate::ui::components::{self, settings_components}; +use crate::ui::icon::Icon; +use crate::widget_size::Priority; +use crate::widget_visibility::ConditionKind; + +pub fn view<'a>(snap: &'a Snapdash, id: window::Id, entity_id: &'a str) -> Element<'a, Message> { + let p = snap.theme.palette; + + // Page title reflects whats on the widget, dialog title updates live + let display_name = snap.display_name(entity_id); + + // HA's friendly_name serves as the placeholder so the user sees what + // the title will be if override is cleared + let friendly_placeholder = snap + .ha + .entities + .get(entity_id) + .and_then(|s| s.attributes.get("friendly_name").and_then(|v| v.as_str())) + .unwrap_or(entity_id); + + let current_override = snap + .config + .widget_names + .get(entity_id) + .map(String::as_str) + .unwrap_or(""); + + let display_section = settings_components::section( + [settings_components::item_with_input( + "Custom name", + Some("Override the title shown on the widget. Empty resets to HA's name."), + friendly_placeholder, + current_override, + { + let entity_id = entity_id.to_owned(); + move |val: String| Message::WidgetNameChanged(entity_id.clone(), val) + }, + None, // no submit action - chnges are live + p, + )], + p, + ); + + // Behavior section + let current_priority = snap + .config + .widget_priorities + .get(entity_id) + .copied() + .unwrap_or_default(); + + let priority_item = settings_components::item_with_picker( + "Priority", + Some("Low → dimmed value · High → accent value + steady ring"), + Priority::ALL.to_vec(), + current_priority, + { + let entity_id = entity_id.to_owned(); + move |new: Priority| Message::WidgetPriorityChanged(entity_id.clone(), new) + }, + p, + ); + + let behavior_section = settings_components::section([priority_item], p); + + // --- Visibility section --- + let visibility_rule = snap.config.widget_visibility.get(entity_id); + + let toggle_item = settings_components::item_with_toggle( + "Show only when…", + Some("Gate this widget by another HA entity's state."), + visibility_rule.is_some(), + { + let entity_id = entity_id.to_owned(); + move |on: bool| Message::WidgetVisibilityToggled(entity_id.clone(), on) + }, + p, + ); + + let mut visibility_items: Vec> = vec![toggle_item]; + + if let Some(rule) = visibility_rule { + // Trigger entity + visibility_items.push(settings_components::item_with_input( + "Trigger entity", + Some("HA entity_id whose state drives visibility (e.g. binary_sensor.washer_running)."), + "entity_id", + rule.trigger.as_str(), + { + let entity_id = entity_id.to_owned(); + move |val: String| Message::WidgetVisibilityTriggerChanged(entity_id.clone(), val) + }, + None, + p, + )); + + visibility_items.push(trigger_hint(&rule.trigger, snap, p)); + + // Condition kind picker + let current_kind = ConditionKind::from_condition(&rule.condition); + visibility_items.push(settings_components::item_with_picker( + "Condition", + None, + ConditionKind::ALL.to_vec(), + current_kind, + { + let entity_id = entity_id.to_owned(); + move |kind: ConditionKind| { + Message::WidgetVisibilityConditionChanged(entity_id.clone(), kind) + } + }, + p, + )); + + // Value input — only when the variant has an editable value. + if let Some(raw) = rule.condition.raw_value() { + let (placeholder, helper) = match current_kind { + ConditionKind::StateEquals | ConditionKind::StateNotEquals => { + ("running", "Compared against the trigger's raw state.") + } + ConditionKind::NumericGt | ConditionKind::NumericLt => { + ("0", "Numeric threshold; invalid numbers hide the widget.") + } + ConditionKind::IsAvailable => ("", ""), + }; + + visibility_items.push(settings_components::item_with_input( + "Value", + Some(helper), + placeholder, + raw, + { + let entity_id = entity_id.to_owned(); + move |val: String| Message::WidgetVisibilityValueChanged(entity_id.clone(), val) + }, + None, + p, + )); + } + visibility_items.push(visibility_preview(rule, snap, p)); + } + + let visibility_section = settings_components::section(visibility_items, p); + + // --- PAGAE + let page = settings_components::page_with_sections( + display_name, + [display_section, behavior_section, visibility_section], + true, + p, + ); + + let body: Element = container(page) + .width(Length::Fill) + .height(Length::Fill) + .padding(metric::PAD) + .into(); + + let outer = column![title_bar(snap, id), body, footer(p, entity_id)].spacing(metric::GAP); + + components::card(outer.into(), p) +} + +/// Persistent top chrome — drag area + close. Mirrors +/// `settings::chrome::title_bar`. +fn title_bar<'a>(snap: &'a Snapdash, id: window::Id) -> Element<'a, Message> { + let p = snap.theme.palette; + row![ + mouse_area( + container(components::title("Widget settings", p)) + .width(Length::Fill) + .padding([4, 0]) + ) + .on_press(Message::StartDrag(id)), + components::pill_button_with( + Icon::Close.text(p).size(text_size::NORMAL), + components::ButtonVisual::pill(p), + Some(Message::CloseWindow(id)), + ), + ] + .spacing(metric::GAP) + .align_y(Alignment::Center) + .into() +} + +/// Footer — currently a dimmed entity_id hint so the user can see the +/// raw HA identifier for whatever they're tweaking. Shape mirrors +/// `settings::chrome::footer` (row, right-aligned info on the end). +fn footer<'a>(p: crate::theme::Palette, entity_id: &'a str) -> Element<'a, Message> { + row![ + space().width(Length::Fill), + components::dimmed(format!("entity: {entity_id}"), p), + ] + .align_y(Alignment::Center) + .into() +} + +/// Small colored hint shown under the Trigger input: green when HA +/// already reports that entity, red when it doesn't (typo / wrong id), +/// dim when the field is empty. +fn trigger_hint<'a>( + trigger: &str, + snap: &'a Snapdash, + p: crate::theme::Palette, +) -> Element<'a, Message> { + if snap.ha.entities.contains_key(trigger) { + components::success_message("Trigger found in Home Assistant".to_string(), p) + } else { + components::error_message( + format!("Trigger not found in HA — `{trigger}` won't match anything"), + p, + ) + } +} + +/// Live verdict of the rule against current HA state. Lets the user +/// confirm the rule does what they expect without closing the dialog. +fn visibility_preview<'a>( + rule: &crate::widget_visibility::VisibilityRule, + snap: &'a Snapdash, + p: crate::theme::Palette, +) -> Element<'a, Message> { + let visible = rule.evaluate(&snap.ha.entities); + + if visible { + components::success_message("Currently visible".to_owned(), p) + } else if !snap.ha.entities.contains_key(&rule.trigger) { + components::error_message( + format!("Currently hidden — trigger `{}` not in HA", rule.trigger), + p, + ) + } else { + components::error_message("Currently hidden — condition not met".to_owned(), p) + } +} diff --git a/src/widget_size.rs b/src/widget_size.rs index aeba036..49900ce 100644 --- a/src/widget_size.rs +++ b/src/widget_size.rs @@ -47,7 +47,7 @@ impl Adaptive { /// prominent the value reads (color) and whether the card gets a steady /// accent ring. Stored per entity in Config (mirrors widget_positions); /// missing key = Normal. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] pub enum Priority { Low, @@ -68,6 +68,12 @@ impl Priority { } } +impl std::fmt::Display for Priority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum WidgetSize { Small, diff --git a/src/widget_visibility.rs b/src/widget_visibility.rs new file mode 100644 index 0000000..98a366f --- /dev/null +++ b/src/widget_visibility.rs @@ -0,0 +1,256 @@ +//! Per-widget visibility rules. A widget can be gated by another HA +//! entity's state — useful when a sensor only makes sense while some +//! device is running (e.g. show "washer time remaining" only while +//! `binary_sensor.washer_running` is `on`). +//! +//! Default: a widget without a rule is always visible. With a rule, +//! visibility = `rule.evaluate(entities)`. An unknown trigger entity +//! evaluates to `false` — hide until confirmed. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::ha::types::EntityState; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum VisibilityCondition { + StateEquals { + value: String, + }, + StateNotEquals { + value: String, + }, + /// Entity is reporting a real value — not `unknown` / `unavailable`. + IsAvailable, + NumericGt { + threshold: String, // String -> easier UI binding + }, + NumericLt { + threshold: String, + }, +} + +impl VisibilityCondition { + /// Return the editable raw string value (if the variant has one). + pub fn raw_value(&self) -> Option<&str> { + match self { + Self::StateEquals { value } | Self::StateNotEquals { value } => Some(value), + Self::IsAvailable => None, + Self::NumericGt { threshold } | Self::NumericLt { threshold } => Some(threshold), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VisibilityRule { + /// entity_id of the trigger. May equal the widget's own entity_id + /// (self-triggering — common case for "show sensor X only while it's + /// reporting something useful"). + pub trigger: String, + pub condition: VisibilityCondition, +} + +impl VisibilityRule { + /// `true` → widget should be visible. Trigger entity not in `entities` + /// → `false` (hide-until-known). Decoupled from `HaState` so it's + /// trivially unit-testable. + pub fn evaluate(&self, entities: &HashMap) -> bool { + let Some(state) = entities.get(&self.trigger) else { + return false; + }; + let raw = state.state.as_str(); + match &self.condition { + VisibilityCondition::StateEquals { value } => raw == value, + VisibilityCondition::StateNotEquals { value } => raw != value, + VisibilityCondition::IsAvailable => raw != "unknown" && raw != "unavailable", + VisibilityCondition::NumericGt { threshold } => { + let Ok(t) = threshold.parse::() else { + return false; + }; + raw.parse::().map(|n| n > t).unwrap_or(false) + } + VisibilityCondition::NumericLt { threshold } => { + let Ok(t) = threshold.parse::() else { + return false; + }; + raw.parse::().map(|n| n < t).unwrap_or(false) + } + } + } +} + +/// UI-facing handle on a condition's *variant* — used by the picker in +/// the widget settings dialog so changing the condition type doesn't +/// require constructing a default variant in the view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConditionKind { + IsAvailable, + StateEquals, + StateNotEquals, + NumericGt, + NumericLt, +} + +impl ConditionKind { + pub const ALL: &[Self] = &[ + Self::IsAvailable, + Self::StateEquals, + Self::StateNotEquals, + Self::NumericGt, + Self::NumericLt, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::IsAvailable => "Is available", + Self::StateEquals => "State equals", + Self::StateNotEquals => "State is not", + Self::NumericGt => "Numeric value >", + Self::NumericLt => "Numeric value <", + } + } + + /// Rebuild a `VisibilityCondition` of this variant, carrying the raw + /// string forward where applicable. Numeric ↔ text switch keeps the + /// string (user may have to retype, but no surprise data loss). + pub fn with_value(self, raw: String) -> VisibilityCondition { + match self { + Self::IsAvailable => VisibilityCondition::IsAvailable, + Self::StateEquals => VisibilityCondition::StateEquals { value: raw }, + Self::StateNotEquals => VisibilityCondition::StateNotEquals { value: raw }, + Self::NumericGt => VisibilityCondition::NumericGt { threshold: raw }, + Self::NumericLt => VisibilityCondition::NumericLt { threshold: raw }, + } + } + + pub fn from_condition(c: &VisibilityCondition) -> Self { + match c { + VisibilityCondition::IsAvailable => Self::IsAvailable, + VisibilityCondition::StateEquals { .. } => Self::StateEquals, + VisibilityCondition::StateNotEquals { .. } => Self::StateNotEquals, + VisibilityCondition::NumericGt { .. } => Self::NumericGt, + VisibilityCondition::NumericLt { .. } => Self::NumericLt, + } + } +} + +impl std::fmt::Display for ConditionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn make(entity_id: &str, state: &str) -> EntityState { + EntityState { + entity_id: entity_id.to_owned(), + state: state.to_owned(), + attributes: BTreeMap::new(), + last_changed: None, + last_updated: None, + } + } + + fn map(states: &[(&str, &str)]) -> HashMap { + states + .iter() + .map(|(id, s)| ((*id).to_owned(), make(id, s))) + .collect() + } + + fn rule(trigger: &str, condition: VisibilityCondition) -> VisibilityRule { + VisibilityRule { + trigger: trigger.into(), + condition, + } + } + + #[test] + fn state_equals_matches() { + let entities = map(&[("binary_sensor.washer", "on")]); + let r = rule( + "binary_sensor.washer", + VisibilityCondition::StateEquals { value: "on".into() }, + ); + assert!(r.evaluate(&entities)); + } + + #[test] + fn state_not_equals_matches_otherwise() { + let entities = map(&[("light.x", "on")]); + let r = rule( + "light.x", + VisibilityCondition::StateNotEquals { + value: "off".into(), + }, + ); + assert!(r.evaluate(&entities)); + } + + #[test] + fn unknown_trigger_evaluates_false() { + let entities = HashMap::new(); + let r = rule("ghost", VisibilityCondition::IsAvailable); + assert!(!r.evaluate(&entities)); + } + + #[test] + fn is_available_excludes_unknown_and_unavailable() { + let r = rule("s", VisibilityCondition::IsAvailable); + assert!(!r.evaluate(&map(&[("s", "unknown")]))); + assert!(!r.evaluate(&map(&[("s", "unavailable")]))); + assert!(r.evaluate(&map(&[("s", "42")]))); + } + + #[test] + fn numeric_thresholds() { + let entities = map(&[("s", "60")]); + assert!( + rule( + "s", + VisibilityCondition::NumericGt { + threshold: "30".into() + } + ) + .evaluate(&entities) + ); + assert!( + !rule( + "s", + VisibilityCondition::NumericGt { + threshold: "90.0".into() + } + ) + .evaluate(&entities) + ); + assert!( + rule( + "s", + VisibilityCondition::NumericLt { + threshold: "90".into() + } + ) + .evaluate(&entities) + ); + } + + #[test] + fn non_numeric_state_fails_numeric_gracefully() { + // "running" can't parse as f64 → NumericGt evaluates to false + // (rule hides the widget instead of crashing or flashing on). + let entities = map(&[("s", "running")]); + let r = rule( + "s", + VisibilityCondition::NumericGt { + threshold: "0.0".into(), + }, + ); + assert!(!r.evaluate(&entities)); + } +}