670 lines
26 KiB
Rust
670 lines
26 KiB
Rust
// SPDX-License-Identifier: MIT
|
|
|
|
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::iced::widget::text::LineHeight;
|
|
use cosmic::widget::{self, autosize, container, rectangle_tracker::{
|
|
RectangleTracker, RectangleUpdate, rectangle_tracker_subscription,
|
|
}};
|
|
use cosmic::{iced_futures, prelude::*, surface};
|
|
use cosmic::applet::cosmic_panel_config::PanelAnchor;
|
|
use cosmic::iced_core::Shadow;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use std::time::Duration;
|
|
|
|
use crate::config::Units;
|
|
|
|
const WTTR_URL: &str = "https://wttr.lerch.org";
|
|
const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15;
|
|
const REQUEST_TIMEOUT_SECS: u64 = 15;
|
|
const MAX_RETRIES: u32 = 10;
|
|
const INITIAL_RETRY_DELAY_SECS: u64 = 5;
|
|
const MAX_RETRY_DELAY_SECS: u64 = 120;
|
|
/// Minimum time between acting on `NetworkManager` signals (debounce).
|
|
const NM_DEBOUNCE_SECS: u64 = 5;
|
|
/// Delay after NM reports connected before we fetch, to let DNS/routing settle.
|
|
const NM_SETTLE_DELAY_SECS: u64 = 2;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Popup geometry
|
|
//
|
|
// The popup surface must have a size that is *constant* across every state it
|
|
// can render (loading placeholder, loaded forecast, error message), and both
|
|
// `toggle_popup` and `view_window` must agree on that size exactly.
|
|
//
|
|
// Violating either invariant previously killed the applet: the compositor was
|
|
// told the surface could be up to 3206px wide (`toggle_popup` measured the
|
|
// forecast in *bytes* via `str::len`, and the box-drawing characters are 3
|
|
// bytes each) while `view_window` laid out 1106px (measuring in *chars*). The
|
|
// popup then also resized when the async fetch replaced "Loading…" with the
|
|
// much wider table. Either way the surface attached a buffer before acking the
|
|
// initial `configure`, and the compositor terminated us with:
|
|
//
|
|
// xdg_surface#83: error 3: must ack the initial configure before attaching buffer
|
|
//
|
|
// Everything below is therefore derived from fixed constants and never from the
|
|
// text currently being displayed.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Column count of the wttr.in `?A&T` forecast table. This layout is a
|
|
/// fixed-width grid, so it does not vary with the reported conditions.
|
|
const FORECAST_COLUMNS: f32 = 125.0;
|
|
/// Line count allotted to the forecast report. The report itself is 38 lines;
|
|
/// the surplus is headroom so a slightly taller report cannot force a resize.
|
|
const FORECAST_LINES: f32 = 40.0;
|
|
/// Font size used for the popup's monospace report.
|
|
const MONO_FONT_SIZE: f32 = 14.0;
|
|
/// Line height used for the popup's monospace report. Set explicitly rather
|
|
/// than left to the theme so the popup's height is fully determined by these
|
|
/// constants instead of by font metrics we would have to guess at.
|
|
const MONO_LINE_HEIGHT: f32 = 18.0;
|
|
/// Approximate advance width of one monospace character at `MONO_FONT_SIZE`.
|
|
const MONO_CHAR_WIDTH: f32 = 8.4;
|
|
/// Padding between the popup's border and its text.
|
|
const POPUP_PADDING: f32 = 12.0;
|
|
|
|
/// D-Bus proxy for monitoring `NetworkManager` connectivity changes.
|
|
#[zbus::proxy(
|
|
interface = "org.freedesktop.NetworkManager",
|
|
default_service = "org.freedesktop.NetworkManager",
|
|
default_path = "/org/freedesktop/NetworkManager"
|
|
)]
|
|
trait NetworkManager {
|
|
/// Signal emitted when the overall NM connection state changes.
|
|
/// `state` values: 70 = Connected (global), 60 = Connected (site),
|
|
/// 50 = Connected (local), 40 = Connecting, etc.
|
|
#[zbus(signal)]
|
|
fn state_changed(&self, state: u32);
|
|
}
|
|
|
|
/// 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 (icon|temp)
|
|
weather_text: String,
|
|
/// Detected location from wttr.in
|
|
location: String,
|
|
/// Last successful update time
|
|
last_updated: String,
|
|
/// Full weather report for popup
|
|
full_weather: String,
|
|
/// Last error from a full-weather fetch, if any. Kept separate from
|
|
/// `full_weather` so that a failed fetch does not poison the cache
|
|
/// and block the next popup-open from retrying.
|
|
full_weather_error: Option<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>>,
|
|
/// Number of consecutive fetch retries (for captive portal / network issues)
|
|
retry_count: u32,
|
|
/// Unit system to request from wttr.in (None = let the service geolocate).
|
|
units: Option<Units>,
|
|
}
|
|
|
|
/// Messages emitted by the applet.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
WeatherUpdate(Result<(String, String), String>),
|
|
FullWeatherUpdate(Result<String, String>),
|
|
RefreshWeather,
|
|
NetworkChanged,
|
|
TogglePopup,
|
|
CloseRequested(window::Id),
|
|
Rectangle(RectangleUpdate<u32>),
|
|
Surface(surface::Action),
|
|
}
|
|
|
|
impl cosmic::Application for AppModel {
|
|
type Executor = cosmic::executor::Default;
|
|
type Flags = Option<Units>;
|
|
type Message = Message;
|
|
const APP_ID: &'static str = "org.lerch.weather";
|
|
|
|
fn core(&self) -> &cosmic::Core {
|
|
&self.core
|
|
}
|
|
|
|
fn core_mut(&mut self) -> &mut cosmic::Core {
|
|
&mut self.core
|
|
}
|
|
|
|
fn init(
|
|
core: cosmic::Core,
|
|
flags: Self::Flags,
|
|
) -> (Self, Task<cosmic::Action<Self::Message>>) {
|
|
let app = AppModel {
|
|
core,
|
|
weather_text: "Loading...".to_string(),
|
|
location: String::new(),
|
|
last_updated: String::new(),
|
|
full_weather: String::new(),
|
|
full_weather_error: None,
|
|
is_loading: true,
|
|
popup: None,
|
|
rectangle: Rectangle::default(),
|
|
rectangle_tracker: None,
|
|
retry_count: 0,
|
|
units: flags,
|
|
};
|
|
|
|
let units = app.units;
|
|
let command = Task::perform(fetch_weather(units), |result| {
|
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
|
});
|
|
|
|
(app, command)
|
|
}
|
|
|
|
fn view(&self) -> Element<'_, Self::Message> {
|
|
let button = if self.is_loading {
|
|
widget::button::custom(widget::text("⏳").font(cosmic::font::bold()))
|
|
.class(cosmic::theme::Button::Text)
|
|
} else {
|
|
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),
|
|
)
|
|
.class(cosmic::theme::Button::Text)
|
|
.on_press(Message::TogglePopup)
|
|
} else {
|
|
widget::button::custom(widget::text(&self.weather_text))
|
|
.class(cosmic::theme::Button::Text)
|
|
.on_press(Message::TogglePopup)
|
|
}
|
|
};
|
|
|
|
let has_popup = self.popup.is_some();
|
|
let tooltip_text = if self.location.is_empty() {
|
|
"Loading...".to_string()
|
|
} else if self.last_updated.is_empty() {
|
|
self.location.clone()
|
|
} else {
|
|
format!("{}\nUpdated: {}", self.location, self.last_updated)
|
|
};
|
|
|
|
let tooltip = self.core.applet.applet_tooltip(
|
|
button,
|
|
tooltip_text,
|
|
has_popup,
|
|
Message::Surface,
|
|
None,
|
|
);
|
|
|
|
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, tooltip).ignore_bounds(true).into()
|
|
} else {
|
|
container(tooltip).into()
|
|
};
|
|
|
|
autosize::autosize(
|
|
container(element).padding(4),
|
|
cosmic::widget::Id::new("weather-autosize"),
|
|
)
|
|
.limits(limits)
|
|
.into()
|
|
}
|
|
|
|
fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> {
|
|
// Pick what to render: the cached forecast, a transient error
|
|
// (not stored in `full_weather` so the next open retries), or a
|
|
// loading placeholder while the in-flight request completes.
|
|
let display_text: String = if !self.full_weather.is_empty() {
|
|
self.full_weather.clone()
|
|
} else if let Some(err) = &self.full_weather_error {
|
|
format!(
|
|
"Failed to load weather:\n{err}\n\nClose and reopen to retry."
|
|
)
|
|
} else {
|
|
"Loading…".to_string()
|
|
};
|
|
|
|
// Pad every state out to a full-height report so that the popup's
|
|
// measured size does not change when the fetch resolves.
|
|
let display_text = pad_to_forecast_height(&display_text);
|
|
|
|
let (content_width, content_height) = popup_content_size();
|
|
let (surface_width, surface_height) = popup_surface_size();
|
|
|
|
// Fixing both axes means the content measures identically no matter
|
|
// which of the three states above is being rendered.
|
|
let content = container(
|
|
widget::text(display_text)
|
|
.font(Font::MONOSPACE)
|
|
.size(MONO_FONT_SIZE)
|
|
.line_height(LineHeight::Absolute(MONO_LINE_HEIGHT.into()))
|
|
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
|
.width(cosmic::iced::Length::Fixed(content_width))
|
|
.height(cosmic::iced::Length::Fixed(content_height)),
|
|
)
|
|
.padding(POPUP_PADDING);
|
|
|
|
// 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(
|
|
// Must match the positioner size limits set in `toggle_popup`.
|
|
Limits::NONE
|
|
.min_width(1.0)
|
|
.min_height(1.0)
|
|
.max_width(surface_width)
|
|
.max_height(surface_height),
|
|
)
|
|
.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)),
|
|
// Periodic weather refresh timer
|
|
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;
|
|
}
|
|
})
|
|
}),
|
|
// NetworkManager connectivity change monitor
|
|
Subscription::run(|| {
|
|
iced_futures::stream::channel(1, |mut emitter| async move {
|
|
let connection = match zbus::Connection::system().await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("Failed to connect to system D-Bus for network monitoring: {e}");
|
|
futures_util::future::pending::<()>().await;
|
|
unreachable!();
|
|
}
|
|
};
|
|
|
|
let proxy = match NetworkManagerProxy::new(&connection).await {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Failed to create NetworkManager proxy: {e}");
|
|
futures_util::future::pending::<()>().await;
|
|
unreachable!();
|
|
}
|
|
};
|
|
|
|
let mut stream = match proxy.receive_state_changed().await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Failed to subscribe to NM StateChanged: {e}");
|
|
futures_util::future::pending::<()>().await;
|
|
unreachable!();
|
|
}
|
|
};
|
|
|
|
let mut last_emit = tokio::time::Instant::now() - Duration::from_secs(NM_DEBOUNCE_SECS + 1);
|
|
while let Some(signal) = stream.next().await {
|
|
if let Ok(args) = signal.args() {
|
|
// NM state >= 60 means some form of network connectivity
|
|
if args.state >= 60 && last_emit.elapsed() >= Duration::from_secs(NM_DEBOUNCE_SECS) {
|
|
// Brief delay to let DNS/routing stabilize
|
|
tokio::time::sleep(Duration::from_secs(NM_SETTLE_DELAY_SECS)).await;
|
|
_ = emitter.send(Message::NetworkChanged).await;
|
|
last_emit = tokio::time::Instant::now();
|
|
}
|
|
}
|
|
}
|
|
eprintln!("NetworkManager signal stream ended unexpectedly");
|
|
})
|
|
}),
|
|
])
|
|
}
|
|
|
|
fn update(&mut self, message: Self::Message) -> Task<cosmic::Action<Self::Message>> {
|
|
match message {
|
|
Message::WeatherUpdate(result) => {
|
|
self.is_loading = false;
|
|
match result {
|
|
Ok((location, weather)) => {
|
|
self.location = location;
|
|
self.weather_text = weather;
|
|
self.last_updated = format_current_time();
|
|
self.retry_count = 0;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Weather fetch error: {e}");
|
|
self.weather_text = if e.contains("network") || e.contains("timeout") || e.contains("captive") || e.contains("Captive") {
|
|
"\u{1f310}\u{274c}".to_string() // Network issue
|
|
} else {
|
|
"\u{274c}".to_string() // Generic error
|
|
};
|
|
|
|
// Retry with exponential backoff (handles captive portals,
|
|
// transient failures after network changes, etc.)
|
|
if self.retry_count < MAX_RETRIES {
|
|
self.retry_count += 1;
|
|
let delay = retry_delay(self.retry_count);
|
|
eprintln!(
|
|
"Scheduling retry {}/{MAX_RETRIES} in {delay}s",
|
|
self.retry_count
|
|
);
|
|
let units = self.units;
|
|
return Task::perform(
|
|
fetch_weather_delayed(delay, units),
|
|
|result| cosmic::Action::App(Message::WeatherUpdate(result)),
|
|
);
|
|
}
|
|
eprintln!(
|
|
"Max retries ({MAX_RETRIES}) reached, waiting for next scheduled refresh"
|
|
);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::FullWeatherUpdate(result) => {
|
|
match result {
|
|
Ok(weather) => {
|
|
self.full_weather = weather;
|
|
self.full_weather_error = None;
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Full weather fetch error: {e}");
|
|
// Leave `full_weather` empty so that the next
|
|
// popup open triggers a refetch (see toggle_popup).
|
|
self.full_weather_error = Some(e);
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::RefreshWeather => {
|
|
self.retry_count = 0;
|
|
// Invalidate the popup cache on each scheduled refresh so
|
|
// that the next popup open fetches a fresh forecast. This
|
|
// piggybacks the popup's freshness on the panel's 15-minute
|
|
// refresh cadence (WEATHER_UPDATE_INTERVAL_MINUTES).
|
|
self.full_weather.clear();
|
|
self.full_weather_error = None;
|
|
let units = self.units;
|
|
Task::perform(fetch_weather(units), |result| {
|
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
|
})
|
|
}
|
|
Message::NetworkChanged => {
|
|
eprintln!("Network connectivity changed, refreshing weather");
|
|
self.retry_count = 0;
|
|
// Clear cached full weather so the popup re-fetches on next open
|
|
self.full_weather.clear();
|
|
self.full_weather_error = None;
|
|
let units = self.units;
|
|
Task::perform(fetch_weather(units), |result| {
|
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
|
})
|
|
}
|
|
Message::TogglePopup => self.toggle_popup(),
|
|
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()
|
|
}
|
|
Message::Surface(action) => {
|
|
cosmic::task::message(cosmic::Action::Cosmic(
|
|
cosmic::app::Action::Surface(action),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppModel {
|
|
fn toggle_popup(&mut self) -> Task<cosmic::Action<Message>> {
|
|
if let Some(p) = self.popup.take() {
|
|
return destroy_popup(p);
|
|
}
|
|
|
|
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;
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
let 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.anchor_rect = anchor_rect;
|
|
|
|
popup_settings.positioner.size = None;
|
|
|
|
// Report the same constant geometry that `view_window` lays out. This
|
|
// previously measured `self.full_weather` in bytes via `str::len()`,
|
|
// which triple-counts the table's 3-byte box-drawing characters and so
|
|
// disagreed with the renderer by ~2100px.
|
|
let (surface_width, surface_height) = popup_surface_size();
|
|
popup_settings.positioner.size_limits = Limits::NONE
|
|
.min_width(1.0)
|
|
.min_height(1.0)
|
|
.max_width(surface_width)
|
|
.max_height(surface_height);
|
|
|
|
if self.full_weather.is_empty() {
|
|
let units = self.units;
|
|
let fetch_task = Task::perform(fetch_full_weather(units), |result| {
|
|
cosmic::Action::App(Message::FullWeatherUpdate(result))
|
|
});
|
|
Task::batch([get_popup(popup_settings), fetch_task])
|
|
} else {
|
|
get_popup(popup_settings)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn fetch_weather(units: Option<Units>) -> Result<(String, String), String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let response = client
|
|
.get(format!(
|
|
"{WTTR_URL}/?format=%l|%c|%t{suffix}",
|
|
suffix = units_suffix(units)
|
|
))
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let text = response.text().await.map_err(|e| e.to_string())?;
|
|
let text = text.trim();
|
|
|
|
// Detect captive portal: the weather API returns plain text, not HTML.
|
|
if looks_like_captive_portal(text) {
|
|
return Err("Captive portal detected: received HTML instead of weather data".to_string());
|
|
}
|
|
|
|
// Format: "location|icon|temp"
|
|
let parts: Vec<&str> = text.splitn(3, '|').collect();
|
|
if parts.len() == 3 {
|
|
let location = parts[0].to_string();
|
|
let weather = format!("{}|{}", parts[1], parts[2]);
|
|
Ok((location, weather))
|
|
} else {
|
|
Ok((String::new(), text.to_string()))
|
|
}
|
|
}
|
|
|
|
fn format_current_time() -> String {
|
|
// Include %H:%M in the wttr.in request would add another field to parse;
|
|
// instead, record the local time when we receive the update.
|
|
// We use /proc/self or a simple UTC-based approach. Since the applet
|
|
// runs locally, we can shell out or use libc. For simplicity, use
|
|
// the `date` command which respects the user's timezone.
|
|
std::process::Command::new("date")
|
|
.arg("+%H:%M")
|
|
.output()
|
|
.ok()
|
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
.map(|s| s.trim().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
async fn fetch_full_weather(units: Option<Units>) -> Result<String, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
|
.build()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let response = client
|
|
.get(format!("{WTTR_URL}?A&T{suffix}", suffix = units_suffix(units)))
|
|
.send()
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let text = response.text().await.map_err(|e| e.to_string())?;
|
|
let text = text.trim();
|
|
|
|
if looks_like_captive_portal(text) {
|
|
return Err("Captive portal detected: received HTML instead of weather data".to_string());
|
|
}
|
|
|
|
Ok(text.to_string())
|
|
}
|
|
|
|
/// Returns `true` if the response body looks like an HTML captive portal page
|
|
/// rather than the expected plain-text weather data.
|
|
fn looks_like_captive_portal(body: &str) -> bool {
|
|
let lower = body.to_ascii_lowercase();
|
|
lower.contains("<html") || lower.contains("<!doctype")
|
|
}
|
|
|
|
/// Compute the retry delay using exponential backoff:
|
|
/// 5s, 10s, 20s, 40s, 80s, 120s, 120s, ...
|
|
fn retry_delay(retry_count: u32) -> u64 {
|
|
let delay = INITIAL_RETRY_DELAY_SECS.saturating_mul(2u64.saturating_pow(retry_count - 1));
|
|
delay.min(MAX_RETRY_DELAY_SECS)
|
|
}
|
|
|
|
/// Fetch weather after waiting `delay_secs`, used for retry backoff.
|
|
async fn fetch_weather_delayed(
|
|
delay_secs: u64,
|
|
units: Option<Units>,
|
|
) -> Result<(String, String), String> {
|
|
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
|
|
fetch_weather(units).await
|
|
}
|
|
|
|
/// Build the URL suffix that selects a unit system on wttr.in.
|
|
/// Returns an empty string when no explicit choice has been made.
|
|
fn units_suffix(units: Option<Units>) -> String {
|
|
match units {
|
|
Some(u) => format!("&{}", u.query_param()),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// Size of the popup's text area, in logical pixels.
|
|
///
|
|
/// Deliberately takes no arguments: the popup is always sized for a full
|
|
/// forecast table regardless of what is currently loaded. See the "Popup
|
|
/// geometry" comment at the top of this module for why this must be constant.
|
|
fn popup_content_size() -> (f32, f32) {
|
|
(
|
|
// One column of slack so a slightly wider glyph advance than
|
|
// `MONO_CHAR_WIDTH` cannot clip the table's right-hand border.
|
|
(FORECAST_COLUMNS + 1.0) * MONO_CHAR_WIDTH,
|
|
FORECAST_LINES * MONO_LINE_HEIGHT,
|
|
)
|
|
}
|
|
|
|
/// Outer size of the popup surface, including `POPUP_PADDING` on all sides.
|
|
///
|
|
/// This is the single value both `toggle_popup` (which reports it to the
|
|
/// compositor as the positioner's size limits) and `view_window` (which uses it
|
|
/// as the autosize limits) must agree on.
|
|
fn popup_surface_size() -> (f32, f32) {
|
|
let (width, height) = popup_content_size();
|
|
(
|
|
width + POPUP_PADDING * 2.0,
|
|
height + POPUP_PADDING * 2.0,
|
|
)
|
|
}
|
|
|
|
/// Pad `text` to exactly `FORECAST_LINES` lines.
|
|
///
|
|
/// This keeps the "Loading…" placeholder and any error message occupying the
|
|
/// same vertical space as a fully loaded forecast, so the popup does not resize
|
|
/// when the in-flight request resolves. Longer text is left intact; the fixed
|
|
/// height of the content container clips it rather than growing the surface.
|
|
fn pad_to_forecast_height(text: &str) -> String {
|
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
let target = FORECAST_LINES as usize;
|
|
let mut padded = text.to_string();
|
|
for _ in text.lines().count()..target {
|
|
padded.push('\n');
|
|
}
|
|
padded
|
|
}
|