Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 178 additions & 3 deletions src/app/snapdash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub struct Snapdash {
pub status: String,

pub entity_windows: HashMap<String, window::Id>,
pub entity_windows_opening: HashSet<String>,
pub boot_open_done: bool,

pub settings_sensors: Vec<SettingsSensor>,
Expand Down Expand Up @@ -90,6 +91,7 @@ pub enum Message {
OpenEntity(String),
OpenReleaseNotes,
OpenUrl(String),
OpenWidgetSettings(String),
CloseWindow(window::Id),
QuitApp,
WindowClosed(window::Id),
Expand Down Expand Up @@ -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<std::path::PathBuf, String>),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Message> {
let mut tasks: Vec<Task<Message>> = 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)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the app alive when a rule hides the last widget

When the only open window is an entity widget and its visibility rule becomes false, this new reconciliation path emits CloseWindow; the existing WindowClosed handler then sees self.windows.is_empty() and calls iced::exit() at src/app/snapdash.rs:1076. In that single-widget/all-widgets-hidden case the process exits instead of staying connected to HA and reopening the widget when the rule becomes true, so conditional visibility can terminate Snapdash.

Useful? React with 👍 / 👎.

}
}

if tasks.is_empty() {
Task::none()
} else {
Task::batch(tasks)
}
}

pub fn update(&mut self, message: Message) -> Task<Message> {
Expand Down Expand Up @@ -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 });
Expand All @@ -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() {
Expand Down Expand Up @@ -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<String> = 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor visibility rules for direct widget opens

When a widget has a saved visibility rule that is currently false, removing and then re-adding it from Settings dispatches OpenEntity(entity_id), but this direct-open branch bypasses the is_widget_visible filter that only runs for the bulk boot-open path. Because ToggleWidget leaves the rule in config.widget_visibility and no HA event is required immediately afterward, a hidden-by-rule widget can remain visible after being re-added.

Useful? React with 👍 / 👎.

};
Expand All @@ -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,
Expand Down Expand Up @@ -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::<Message>(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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1336,6 +1510,7 @@ impl Snapdash {
WindowKind::Settings => inner,
WindowKind::ReleaseNotes => inner,
WindowKind::ThemeGallery => inner,
WindowKind::WidgetSettings { .. } => inner,
};

// Platform-specific outer wrapping:
Expand Down
1 change: 1 addition & 0 deletions src/app/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub enum WindowKind {
Entity { entity_id: String },
ReleaseNotes,
ThemeGallery,
WidgetSettings { entity_id: String },
}

#[derive(Debug, Clone)]
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct Config {
pub widget_positions: HashMap<String, WidgetPosition>,
#[serde(default)]
pub widget_priorities: HashMap<String, Priority>,
#[serde(default)]
pub widget_names: HashMap<String, String>,
#[serde(default)]
pub widget_visibility: HashMap<String, crate::widget_visibility::VisibilityRule>,
}

#[derive(Clone, Debug, Copy, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod theme;
pub mod ui;
pub mod update;
pub mod widget_size;
pub mod widget_visibility;

use iced::daemon;

Expand Down
26 changes: 25 additions & 1 deletion src/ui/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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<Message> = 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
Expand All @@ -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> {
Expand Down Expand Up @@ -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(),
}
}
Loading
Loading