ai: work around popup size and get the width right

This commit is contained in:
Emil Lerch 2026-03-02 13:12:52 -08:00
parent 934e0d24fa
commit 0e2a34c520
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 230 additions and 27 deletions

View file

@ -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 := ''

View file

@ -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<window::Id>,
/// Button rectangle for popup positioning
rectangle: Rectangle,
/// Rectangle tracker
rectangle_tracker: Option<RectangleTracker<u32>>,
}
/// Messages emitted by the applet.
#[derive(Debug, Clone)]
pub enum Message {
WeatherUpdate(Result<String, String>),
FullWeatherUpdate(Result<String, String>),
RefreshWeather,
TogglePopup,
CloseRequested(window::Id),
Rectangle(RectangleUpdate<u32>),
}
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<Self::Message> {
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<Self::Message> {
Some(Message::CloseRequested(id))
}
fn subscription(&self) -> Subscription<Self::Message> {
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<cosmic::Action<Self::Message>> {
@ -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::<i32> {
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<String, String> {
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<String, String> {
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())
}