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 of the application's binary.
|
||||||
name := 'cosmic-weather-applet'
|
name := 'cosmic-weather-applet'
|
||||||
# The unique ID of the application.
|
# 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 `/`.
|
# Path to root file system, which defaults to `/`.
|
||||||
rootdir := ''
|
rootdir := ''
|
||||||
|
|
|
||||||
229
src/app.rs
229
src/app.rs
|
|
@ -1,26 +1,45 @@
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
use cosmic::iced::{Limits, Subscription};
|
use cosmic::iced::{Color, Font, Limits, Rectangle, Subscription, window};
|
||||||
use cosmic::widget::{self, autosize, container};
|
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::{iced_futures, prelude::*};
|
||||||
|
use cosmic::applet::cosmic_panel_config::PanelAnchor;
|
||||||
|
use cosmic::iced_core::Shadow;
|
||||||
use futures_util::SinkExt;
|
use futures_util::SinkExt;
|
||||||
use std::time::Duration;
|
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.
|
/// The applet model stores app-specific state.
|
||||||
pub struct AppModel {
|
pub struct AppModel {
|
||||||
/// Application state which is managed by the COSMIC runtime.
|
/// Application state which is managed by the COSMIC runtime.
|
||||||
core: cosmic::Core,
|
core: cosmic::Core,
|
||||||
/// Current weather data
|
/// Current weather data
|
||||||
weather_text: String,
|
weather_text: String,
|
||||||
|
/// Full weather report for popup
|
||||||
|
full_weather: String,
|
||||||
/// Loading state
|
/// Loading state
|
||||||
is_loading: bool,
|
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.
|
/// Messages emitted by the applet.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
WeatherUpdate(Result<String, String>),
|
WeatherUpdate(Result<String, String>),
|
||||||
|
FullWeatherUpdate(Result<String, String>),
|
||||||
RefreshWeather,
|
RefreshWeather,
|
||||||
|
TogglePopup,
|
||||||
|
CloseRequested(window::Id),
|
||||||
|
Rectangle(RectangleUpdate<u32>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl cosmic::Application for AppModel {
|
impl cosmic::Application for AppModel {
|
||||||
|
|
@ -44,7 +63,11 @@ impl cosmic::Application for AppModel {
|
||||||
let app = AppModel {
|
let app = AppModel {
|
||||||
core,
|
core,
|
||||||
weather_text: "Loading...".to_string(),
|
weather_text: "Loading...".to_string(),
|
||||||
|
full_weather: String::new(),
|
||||||
is_loading: true,
|
is_loading: true,
|
||||||
|
popup: None,
|
||||||
|
rectangle: Rectangle::default(),
|
||||||
|
rectangle_tracker: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let command = Task::perform(fetch_weather(), |result| {
|
let command = Task::perform(fetch_weather(), |result| {
|
||||||
|
|
@ -55,37 +78,118 @@ impl cosmic::Application for AppModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self) -> Element<'_, Self::Message> {
|
fn view(&self) -> Element<'_, Self::Message> {
|
||||||
let content: Element<'_, Self::Message> = if self.is_loading {
|
let button = if self.is_loading {
|
||||||
widget::text("⏳").font(cosmic::font::bold()).into()
|
widget::button::custom(widget::text("⏳").font(cosmic::font::bold()))
|
||||||
|
.class(cosmic::theme::Button::Text)
|
||||||
} else {
|
} else {
|
||||||
// Split weather text on pipe separator
|
|
||||||
let parts: Vec<&str> = self.weather_text.split('|').collect();
|
let parts: Vec<&str> = self.weather_text.split('|').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
|
widget::button::custom(
|
||||||
widget::row()
|
widget::row()
|
||||||
.push(widget::text(parts[0]).font(cosmic::font::bold()))
|
.push(widget::text(parts[0]).font(cosmic::font::bold()))
|
||||||
.push(widget::text("|"))
|
.push(widget::text("|"))
|
||||||
.push(widget::text(parts[1]))
|
.push(widget::text(parts[1]))
|
||||||
.spacing(0)
|
.spacing(0),
|
||||||
.into()
|
)
|
||||||
|
.class(cosmic::theme::Button::Text)
|
||||||
|
.on_press(Message::TogglePopup)
|
||||||
} else {
|
} 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 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(
|
autosize::autosize(
|
||||||
container(content).padding(4),
|
container(element).padding(4),
|
||||||
cosmic::widget::Id::new("weather-autosize"),
|
cosmic::widget::Id::new("weather-autosize"),
|
||||||
)
|
)
|
||||||
.limits(limits)
|
.limits(limits)
|
||||||
.into()
|
.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> {
|
fn subscription(&self) -> Subscription<Self::Message> {
|
||||||
|
Subscription::batch([
|
||||||
|
rectangle_tracker_subscription(0).map(|e| Message::Rectangle(e.1)),
|
||||||
Subscription::run(|| {
|
Subscription::run(|| {
|
||||||
iced_futures::stream::channel(1, |mut emitter| async move {
|
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
|
interval.tick().await; // Skip first tick
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -93,7 +197,8 @@ impl cosmic::Application for AppModel {
|
||||||
_ = emitter.send(Message::RefreshWeather).await;
|
_ = emitter.send(Message::RefreshWeather).await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, message: Self::Message) -> Task<cosmic::Action<Self::Message>> {
|
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;
|
self.is_loading = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(weather) => self.weather_text = weather,
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
@ -112,15 +236,94 @@ impl cosmic::Application for AppModel {
|
||||||
cosmic::Action::App(Message::WeatherUpdate(result))
|
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> {
|
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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let text = response.text().await.map_err(|e| e.to_string())?;
|
let text = response.text().await.map_err(|e| e.to_string())?;
|
||||||
Ok(text.trim().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