diff --git a/justfile b/justfile index 7b988e4..16cd392 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ # Name of the application's binary. name := 'cosmic-weather-applet' # The unique ID of the application. -appid := 'com.github.elerch.cosmic-weather-applet' +appid := 'org.lerch.weather' # Path to root file system, which defaults to `/`. rootdir := '' diff --git a/src/app.rs b/src/app.rs index 02e7b44..fd9c515 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,26 +1,45 @@ // SPDX-License-Identifier: MIT -use cosmic::iced::{Limits, Subscription}; -use cosmic::widget::{self, autosize, container}; +use cosmic::iced::{Color, Font, Limits, Rectangle, Subscription, window}; +use cosmic::iced::alignment::{Horizontal, Vertical}; +use cosmic::iced::platform_specific::shell::wayland::commands::popup::{destroy_popup, get_popup}; +use cosmic::widget::{self, autosize, container, rectangle_tracker::*}; use cosmic::{iced_futures, prelude::*}; +use cosmic::applet::cosmic_panel_config::PanelAnchor; +use cosmic::iced_core::Shadow; use futures_util::SinkExt; use std::time::Duration; +const WTTR_URL: &str = "https://wttr.lerch.org"; +const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15; + /// The applet model stores app-specific state. pub struct AppModel { /// Application state which is managed by the COSMIC runtime. core: cosmic::Core, /// Current weather data weather_text: String, + /// Full weather report for popup + full_weather: String, /// Loading state is_loading: bool, + /// Popup window ID + popup: Option, + /// Button rectangle for popup positioning + rectangle: Rectangle, + /// Rectangle tracker + rectangle_tracker: Option>, } /// Messages emitted by the applet. #[derive(Debug, Clone)] pub enum Message { WeatherUpdate(Result), + FullWeatherUpdate(Result), RefreshWeather, + TogglePopup, + CloseRequested(window::Id), + Rectangle(RectangleUpdate), } impl cosmic::Application for AppModel { @@ -44,7 +63,11 @@ impl cosmic::Application for AppModel { let app = AppModel { core, weather_text: "Loading...".to_string(), + full_weather: String::new(), is_loading: true, + popup: None, + rectangle: Rectangle::default(), + rectangle_tracker: None, }; let command = Task::perform(fetch_weather(), |result| { @@ -55,45 +78,127 @@ impl cosmic::Application for AppModel { } fn view(&self) -> Element<'_, Self::Message> { - let content: Element<'_, Self::Message> = if self.is_loading { - widget::text("⏳").font(cosmic::font::bold()).into() + let button = if self.is_loading { + widget::button::custom(widget::text("⏳").font(cosmic::font::bold())) + .class(cosmic::theme::Button::Text) } else { - // Split weather text on pipe separator let parts: Vec<&str> = self.weather_text.split('|').collect(); if parts.len() == 2 { - widget::row() - .push(widget::text(parts[0]).font(cosmic::font::bold())) - .push(widget::text("|")) - .push(widget::text(parts[1])) - .spacing(0) - .into() + widget::button::custom( + widget::row() + .push(widget::text(parts[0]).font(cosmic::font::bold())) + .push(widget::text("|")) + .push(widget::text(parts[1])) + .spacing(0), + ) + .class(cosmic::theme::Button::Text) + .on_press(Message::TogglePopup) } else { - widget::text(&self.weather_text).into() + widget::button::custom(widget::text(&self.weather_text)) + .class(cosmic::theme::Button::Text) + .on_press(Message::TogglePopup) } }; let limits = Limits::NONE.min_width(1.).min_height(1.); + let element: Element<'_, Self::Message> = if let Some(tracker) = self.rectangle_tracker.as_ref() { + tracker.container(0, button).ignore_bounds(true).into() + } else { + container(button).into() + }; + autosize::autosize( - container(content).padding(4), + container(element).padding(4), cosmic::widget::Id::new("weather-autosize"), ) .limits(limits) .into() } - fn subscription(&self) -> Subscription { - Subscription::run(|| { - iced_futures::stream::channel(1, |mut emitter| async move { - let mut interval = tokio::time::interval(Duration::from_secs(15 * 60)); // 15 minutes - interval.tick().await; // Skip first tick + fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { + // Calculate the width needed for the monospace content. + // The default monospace font is ~8.4px per character at 14px font size, + // plus padding for container (12*2) and popup chrome (~32). + let max_line_len = self.full_weather.lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(40) as f32; + let estimated_char_width = 8.4; + let content_width = max_line_len * estimated_char_width + 56.0; + let popup_width = content_width.max(400.0); - loop { - interval.tick().await; - _ = emitter.send(Message::RefreshWeather).await; - } - }) - }) + let content = container( + widget::text(&self.full_weather) + .font(Font::MONOSPACE) + .wrapping(cosmic::iced::widget::text::Wrapping::None) + .width(cosmic::iced::Length::Fixed(content_width)) + ) + .padding(12); + + // Build our own popup container instead of using + // self.core.applet.popup_container() which hardcodes max_width(360). + // See: https://github.com/pop-os/libcosmic/issues/717 + let (vertical_align, horizontal_align) = match self.core.applet.anchor { + PanelAnchor::Left => (Vertical::Center, Horizontal::Left), + PanelAnchor::Right => (Vertical::Center, Horizontal::Right), + PanelAnchor::Top => (Vertical::Top, Horizontal::Center), + PanelAnchor::Bottom => (Vertical::Bottom, Horizontal::Center), + }; + + autosize::autosize( + container( + container(content).class(cosmic::style::Container::custom(|theme| { + let cosmic = theme.cosmic(); + let corners = cosmic.corner_radii; + cosmic::iced_widget::container::Style { + text_color: Some(cosmic.background.on.into()), + background: Some(Color::from(cosmic.background.base).into()), + border: cosmic::iced::Border { + radius: corners.radius_m.into(), + width: 1.0, + color: cosmic.background.divider.into(), + }, + shadow: Shadow::default(), + icon_color: Some(cosmic.background.on.into()), + } + })) + ) + .width(cosmic::iced::Length::Shrink) + .height(cosmic::iced::Length::Shrink) + .align_x(horizontal_align) + .align_y(vertical_align), + cosmic::widget::Id::new("weather-popup-autosize"), + ) + .limits( + Limits::NONE + .min_width(1.0) + .min_height(1.0) + .max_width(popup_width) + .max_height(1080.0), + ) + .into() + } + + fn on_close_requested(&self, id: window::Id) -> Option { + Some(Message::CloseRequested(id)) + } + + fn subscription(&self) -> Subscription { + Subscription::batch([ + rectangle_tracker_subscription(0).map(|e| Message::Rectangle(e.1)), + Subscription::run(|| { + iced_futures::stream::channel(1, |mut emitter| async move { + let mut interval = tokio::time::interval(Duration::from_secs(WEATHER_UPDATE_INTERVAL_MINUTES * 60)); + interval.tick().await; // Skip first tick + + loop { + interval.tick().await; + _ = emitter.send(Message::RefreshWeather).await; + } + }) + }), + ]) } fn update(&mut self, message: Self::Message) -> Task> { @@ -102,7 +207,26 @@ impl cosmic::Application for AppModel { self.is_loading = false; match result { Ok(weather) => self.weather_text = weather, - Err(_) => self.weather_text = "❌".to_string(), + Err(e) => { + eprintln!("Weather fetch error: {}", e); + self.weather_text = if e.contains("network") || e.contains("timeout") { + "🌐❌".to_string() // Network issue + } else { + "❌".to_string() // Generic error + }; + } + } + Task::none() + } + Message::FullWeatherUpdate(result) => { + match result { + Ok(weather) => { + self.full_weather = weather; + } + Err(e) => { + eprintln!("Full weather fetch error: {}", e); + self.full_weather = "Failed to load weather".to_string(); + } } Task::none() } @@ -112,15 +236,94 @@ impl cosmic::Application for AppModel { cosmic::Action::App(Message::WeatherUpdate(result)) }) } + Message::TogglePopup => { + if let Some(p) = self.popup.take() { + destroy_popup(p) + } else { + let new_id = window::Id::unique(); + self.popup = Some(new_id); + + let mut popup_settings = self.core.applet.get_popup_settings( + self.core.main_window_id().unwrap(), + new_id, + None, + None, + None, + ); + + let Rectangle { x, y, width, height } = self.rectangle; + popup_settings.positioner.anchor_rect = Rectangle:: { + x: x.max(1.) as i32, + y: y.max(1.) as i32, + width: width.max(1.) as i32, + height: height.max(1.) as i32, + }; + + popup_settings.positioner.size = None; + + // Compute popup width from the weather content. + // ~8.4px per monospace character at default font size, + // plus padding for container (12*2) and popup chrome (~32). + let max_line_len = self.full_weather.lines() + .map(|line| line.len()) + .max() + .unwrap_or(0) as f32; + // Use content-based width if available, otherwise a + // reasonable default for the wttr.in 4-column table. + let popup_width = if max_line_len > 0.0 { + (max_line_len * 8.4 + 56.0).max(400.0) + } else { + 1100.0 + }; + popup_settings.positioner.size_limits = Limits::NONE + .min_width(1.0) + .min_height(1.0) + .max_width(popup_width) + .max_height(1080.0); + + if self.full_weather.is_empty() { + let fetch_task = Task::perform(fetch_full_weather(), |result| { + cosmic::Action::App(Message::FullWeatherUpdate(result)) + }); + Task::batch([get_popup(popup_settings), fetch_task]) + } else { + get_popup(popup_settings) + } + } + } + Message::CloseRequested(id) => { + if Some(id) == self.popup { + self.popup = None; + } + Task::none() + } + Message::Rectangle(u) => { + match u { + RectangleUpdate::Rectangle(r) => { + self.rectangle = r.1; + } + RectangleUpdate::Init(tracker) => { + self.rectangle_tracker = Some(tracker); + } + } + Task::none() + } } } } async fn fetch_weather() -> Result { - let response = reqwest::get("https://wttr.lerch.org/?format=%c|%t") + let response = reqwest::get(&format!("{}/?format=%c|%t", WTTR_URL)) .await .map_err(|e| e.to_string())?; let text = response.text().await.map_err(|e| e.to_string())?; Ok(text.trim().to_string()) } + +async fn fetch_full_weather() -> Result { + let response = reqwest::get(&format!("{}?A&T", WTTR_URL)).await.map_err(|e| e.to_string())?; + + let text = response.text().await.map_err(|e| e.to_string())?; + Ok(text.trim().to_string()) +}