ai: work around popup size and get the width right
This commit is contained in:
parent
934e0d24fa
commit
0e2a34c520
2 changed files with 230 additions and 27 deletions
2
justfile
2
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 := ''
|
||||
|
|
|
|||
229
src/app.rs
229
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<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,37 +78,118 @@ 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::button::custom(
|
||||
widget::row()
|
||||
.push(widget::text(parts[0]).font(cosmic::font::bold()))
|
||||
.push(widget::text("|"))
|
||||
.push(widget::text(parts[1]))
|
||||
.spacing(0)
|
||||
.into()
|
||||
.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 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);
|
||||
|
||||
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(15 * 60)); // 15 minutes
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(WEATHER_UPDATE_INTERVAL_MINUTES * 60));
|
||||
interval.tick().await; // Skip first tick
|
||||
|
||||
loop {
|
||||
|
|
@ -93,7 +197,8 @@ impl cosmic::Application for AppModel {
|
|||
_ = 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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue