cosmic-applets/cosmic-applet-time/src/window.rs

631 lines
22 KiB
Rust
Raw Normal View History

2024-05-06 15:39:04 +02:00
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
2024-07-09 15:17:44 +02:00
use std::{borrow::Cow, str::FromStr};
2024-05-25 05:16:25 +02:00
use chrono::{Datelike, DurationRound, Timelike};
use cosmic::{
2024-07-09 15:17:44 +02:00
app,
applet::{cosmic_panel_config::PanelAnchor, menu_button, padded_control},
cctk::sctk::reexports::calloop,
iced::{
futures::{FutureExt, TryFutureExt},
2024-07-09 15:17:44 +02:00
subscription,
wayland::popup::{destroy_popup, get_popup},
widget::{column, row, text, vertical_space},
window, Alignment, Length, Rectangle, Subscription,
},
iced_core::alignment::{Horizontal, Vertical},
iced_runtime::command,
2024-07-09 15:17:44 +02:00
iced_style::application,
iced_widget::{horizontal_rule, Column},
widget::{
button, container, divider, grid, horizontal_space, icon, rectangle_tracker::*, Button,
Grid, Space,
},
Command, Element, Theme,
};
use timedate_zbus::TimeDateProxy;
use zbus::Connection;
2024-07-09 15:17:44 +02:00
use icu::{
calendar::DateTime,
datetime::{
options::{
components::{self, Bag},
preferences,
},
DateTimeFormatter, DateTimeFormatterOptions,
},
locid::Locale,
};
2024-07-09 15:17:44 +02:00
use crate::{config::TimeAppletConfig, fl, time::get_calender_first};
use cosmic::applet::token::subscription::{
activation_token_subscription, TokenRequest, TokenUpdate,
};
2024-05-25 05:16:25 +02:00
/// In order to keep the understandable, the chrono types are not globals,
/// to avoid conflict with icu
pub struct Window {
core: cosmic::app::Core,
popup: Option<window::Id>,
now: chrono::DateTime<chrono::FixedOffset>,
timezone: Option<chrono_tz::Tz>,
2024-05-25 05:16:25 +02:00
date_selected: chrono::NaiveDate,
rectangle_tracker: Option<RectangleTracker<u32>>,
rectangle: Rectangle,
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
config: TimeAppletConfig,
2024-05-25 05:16:25 +02:00
locale: Locale,
conn: Option<Connection>,
}
#[derive(Debug, Clone)]
pub enum Message {
Init(Option<Connection>),
TogglePopup,
CloseRequested(window::Id),
Tick,
Rectangle(RectangleUpdate<u32>),
SelectDay(u32),
PreviousMonth,
NextMonth,
OpenDateTimeSettings,
Token(TokenUpdate),
ConfigChanged(TimeAppletConfig),
TimezoneUpdate(Option<String>),
}
2024-05-25 05:16:25 +02:00
impl Window {
fn format<D: Datelike>(&self, bag: Bag, date: &D) -> String {
let options = DateTimeFormatterOptions::Components(bag);
let dtf =
DateTimeFormatter::try_new_experimental(&self.locale.clone().into(), options).unwrap();
let datetime = DateTime::try_new_gregorian_datetime(
date.year(),
date.month() as u8,
date.day() as u8,
// hack cause we know that we will only use "now"
// when we need hours (NaiveDate don't support this functions)
self.now.hour() as u8,
self.now.minute() as u8,
self.now.second() as u8,
)
.unwrap()
.to_iso()
.to_any();
dtf.format(&datetime)
.expect("can't format value")
.to_string()
}
}
impl cosmic::Application for Window {
type Message = Message;
type Executor = cosmic::SingleThreadExecutor;
type Flags = ();
const APP_ID: &'static str = "com.system76.CosmicAppletTime";
fn init(
core: app::Core,
_flags: Self::Flags,
) -> (Self, cosmic::iced::Command<app::Message<Self::Message>>) {
2024-05-25 05:16:25 +02:00
fn get_local() -> Result<Locale, Box<dyn std::error::Error>> {
let locale = std::env::var("LANG")?;
let locale = locale
.split('.')
.next()
.ok_or(format!("Can't split the locale {locale}"))?;
let locale = Locale::from_str(locale).map_err(|e| format!("{e:?}"))?;
Ok(locale)
}
let locale = match get_local() {
Ok(locale) => locale,
Err(e) => {
tracing::error!("can't get locale {e}");
Locale::default()
}
};
// Chrono evaluates the local timezone once whereby it's stored in a thread local
// variable but never updated
// Instead of using the local timezone, we will store an offset that is updated if the
// timezone is ever externally changed
let now: chrono::DateTime<chrono::FixedOffset> = chrono::Local::now().fixed_offset();
2024-05-25 05:16:25 +02:00
(
2023-11-16 18:32:31 +00:00
Self {
core,
popup: None,
2024-02-26 10:24:07 -08:00
now,
timezone: None,
2024-05-25 05:16:25 +02:00
date_selected: chrono::NaiveDate::from(now.naive_local()),
rectangle_tracker: None,
rectangle: Rectangle::default(),
token_tx: None,
config: TimeAppletConfig::default(),
2024-05-25 05:16:25 +02:00
locale,
conn: None,
},
Command::single(command::Action::Future(Box::pin(Connection::system())))
.map(|res| Message::Init(res.ok()).into()),
)
}
fn core(&self) -> &cosmic::app::Core {
&self.core
}
fn core_mut(&mut self) -> &mut cosmic::app::Core {
&mut self.core
}
fn style(&self) -> Option<<Theme as application::StyleSheet>::Style> {
Some(cosmic::applet::style())
}
fn subscription(&self) -> Subscription<Message> {
2024-05-25 05:16:25 +02:00
fn time_subscription() -> Subscription<()> {
subscription::unfold("time-sub", (), move |()| async move {
let now = chrono::Local::now();
let update_delay = chrono::TimeDelta::minutes(1);
let duration = ((now + update_delay).duration_trunc(update_delay).unwrap() - now)
.to_std()
.unwrap();
tokio::time::sleep(duration).await;
((), ())
})
}
let conn = self.conn.clone();
fn timezone_subscription(conn: Option<Connection>) -> Subscription<Message> {
use cosmic::iced_futures::futures::StreamExt;
let Some(conn) = conn else {
return Subscription::none();
};
// Update applet's timezone if the system's timezone changes
subscription::unfold("timezone-sub", (), move |_| {
let conn = conn.clone();
async move {
TimeDateProxy::new(&conn)
.inspect_err(|e| {
tracing::error!("Failed to connect to timedate endpoint: {e:?}")
})
.then(|proxy| async move {
let Ok(proxy) = proxy else {
return (Message::TimezoneUpdate(None), ());
};
proxy
.receive_timezone_changed()
.then(|stream| stream.into_future())
.then(|(prop_opt, _)| async move {
if let Some(property) = prop_opt {
property
.get()
.await
.inspect_err(|e| {
tracing::error!(
"Failed to receive time zone update: {e:?}"
)
})
.ok()
.map(|tz| (Message::TimezoneUpdate(Some(tz)), ()))
.unwrap_or((Message::TimezoneUpdate(None), ()))
} else {
(Message::TimezoneUpdate(None), ())
}
})
.await
})
.await
}
})
}
Subscription::batch(vec![
rectangle_tracker_subscription(0).map(|e| Message::Rectangle(e.1)),
2024-05-25 05:16:25 +02:00
time_subscription().map(|_| Message::Tick),
activation_token_subscription(0).map(Message::Token),
timezone_subscription(conn),
self.core.watch_config(Self::APP_ID).map(|u| {
for err in u.errors {
tracing::error!(?err, "Error watching config");
}
Message::ConfigChanged(u.config)
}),
])
}
fn update(
&mut self,
message: Self::Message,
) -> cosmic::iced::Command<app::Message<Self::Message>> {
match message {
Message::Init(conn) => {
self.conn = conn;
Command::none()
}
Message::TogglePopup => {
if let Some(p) = self.popup.take() {
destroy_popup(p)
} else {
2024-05-25 05:16:25 +02:00
self.date_selected = chrono::NaiveDate::from(self.now.naive_local());
2024-04-04 04:53:33 -07:00
2023-12-11 14:45:36 -05:00
let new_id = window::Id::unique();
self.popup.replace(new_id);
let mut popup_settings = self.core.applet.get_popup_settings(
2023-12-11 14:45:36 -05:00
window::Id::MAIN,
new_id,
None,
None,
None,
);
let Rectangle {
x,
y,
width,
height,
} = self.rectangle;
popup_settings.positioner.anchor_rect = Rectangle::<i32> {
x: x as i32,
y: y as i32,
width: width as i32,
height: height as i32,
};
get_popup(popup_settings)
}
}
Message::Tick => {
self.now = self
.timezone
.map(|tz| chrono::Local::now().with_timezone(&tz).fixed_offset())
.unwrap_or_else(|| chrono::Local::now().into());
Command::none()
}
Message::Rectangle(u) => {
match u {
RectangleUpdate::Rectangle(r) => {
self.rectangle = r.1;
}
RectangleUpdate::Init(tracker) => {
self.rectangle_tracker = Some(tracker);
}
}
Command::none()
}
Message::CloseRequested(id) => {
if Some(id) == self.popup {
self.popup = None;
}
Command::none()
}
Message::SelectDay(_day) => {
2024-04-04 04:53:33 -07:00
if let Some(date) = self.date_selected.with_day(_day) {
self.date_selected = date;
} else {
tracing::error!("invalid naivedate");
}
Command::none()
}
Message::PreviousMonth => {
2024-05-25 05:16:25 +02:00
if let Some(date) = self
.date_selected
.checked_sub_months(chrono::Months::new(1))
{
2024-04-04 04:53:33 -07:00
self.date_selected = date;
} else {
tracing::error!("invalid naivedate");
}
Command::none()
}
Message::NextMonth => {
2024-05-25 05:16:25 +02:00
if let Some(date) = self
.date_selected
.checked_add_months(chrono::Months::new(1))
{
2024-04-04 04:53:33 -07:00
self.date_selected = date;
} else {
tracing::error!("invalid naivedate");
}
Command::none()
}
Message::OpenDateTimeSettings => {
let exec = "cosmic-settings time".to_string();
if let Some(tx) = self.token_tx.as_ref() {
let _ = tx.send(TokenRequest {
app_id: Self::APP_ID.to_string(),
exec,
});
} else {
tracing::error!("Wayland tx is None");
};
Command::none()
}
Message::Token(u) => {
match u {
TokenUpdate::Init(tx) => {
self.token_tx = Some(tx);
}
TokenUpdate::Finished => {
self.token_tx = None;
}
TokenUpdate::ActivationToken { token, .. } => {
let mut cmd = std::process::Command::new("cosmic-settings");
cmd.arg("time");
if let Some(token) = token {
cmd.env("XDG_ACTIVATION_TOKEN", &token);
cmd.env("DESKTOP_STARTUP_ID", &token);
}
tokio::spawn(cosmic::process::spawn(cmd));
}
}
Command::none()
}
Message::ConfigChanged(c) => {
self.config = c;
Command::none()
}
Message::TimezoneUpdate(timezone) => {
if let Some(timezone) =
timezone.and_then(|timezone| timezone.parse::<chrono_tz::Tz>().ok())
{
self.now = chrono::Local::now().with_timezone(&timezone).fixed_offset();
self.timezone = Some(timezone);
}
self.update(Message::Tick)
}
}
}
fn view(&self) -> Element<Message> {
let horizontal = matches!(
self.core.applet.anchor,
PanelAnchor::Top | PanelAnchor::Bottom
);
2024-05-25 05:16:25 +02:00
let button = cosmic::widget::button(if horizontal {
2024-05-25 05:16:25 +02:00
let mut time: Vec<Cow<'static, str>> = Vec::new();
if self.config.show_date_in_top_panel {
let mut date_bag = Bag::empty();
if self.config.show_weekday {
date_bag.weekday = Some(components::Text::Short);
}
date_bag.day = Some(components::Day::NumericDayOfMonth);
date_bag.month = Some(components::Month::Long);
time.push(format!("{} ", self.format(date_bag, &self.now)).into());
}
let mut time_bag = Bag::empty();
time_bag.hour = Some(components::Numeric::Numeric);
time_bag.minute = Some(components::Numeric::Numeric);
let hour_cycle = if self.config.military_time {
preferences::HourCycle::H23
} else {
preferences::HourCycle::H12
};
2024-05-25 05:16:25 +02:00
time_bag.preferences = Some(preferences::Bag::from_hour_cycle(hour_cycle));
time.push(self.format(time_bag, &self.now).into());
Element::from(
row!(
2024-05-25 05:16:25 +02:00
self.core.applet.text(time.concat()),
container(vertical_space(Length::Fixed(
2024-04-15 18:37:00 -04:00
(self.core.applet.suggested_size(true).1
+ 2 * self.core.applet.suggested_padding(true))
as f32
)))
)
.align_items(Alignment::Center),
)
} else {
2024-05-25 05:16:25 +02:00
// vertical layout
let mut elements = Vec::new();
if self.config.show_date_in_top_panel {
2024-05-25 05:16:25 +02:00
let mut date_bag = Bag::empty();
date_bag.day = Some(components::Day::NumericDayOfMonth);
date_bag.month = Some(components::Month::Short);
2024-05-25 05:16:25 +02:00
let formated = self.format(date_bag, &self.now);
for p in formated.split_whitespace() {
elements.push(self.core.applet.text(p.to_owned()).into());
}
2024-05-25 05:16:25 +02:00
elements.push(
horizontal_rule(2)
.width(self.core.applet.suggested_size(true).0)
.into(),
)
}
let mut time_bag: Bag = Bag::empty();
time_bag.hour = Some(components::Numeric::Numeric);
time_bag.minute = Some(components::Numeric::Numeric);
let hour_cycle = if self.config.military_time {
preferences::HourCycle::H23
} else {
preferences::HourCycle::H12
};
time_bag.preferences = Some(preferences::Bag::from_hour_cycle(hour_cycle));
let formated = self.format(time_bag, &self.now);
// todo: split using formatToParts when it is implemented
// https://github.com/unicode-org/icu4x/issues/4936#issuecomment-2128812667
for p in formated.split_whitespace().flat_map(|s| s.split(':')) {
elements.push(self.core.applet.text(p.to_owned()).into());
}
2024-05-25 05:16:25 +02:00
let date_time_col = Column::with_children(elements)
.align_items(Alignment::Center)
.spacing(4);
Element::from(
column!(
date_time_col,
horizontal_space(Length::Fixed(
2024-04-15 18:37:00 -04:00
(self.core.applet.suggested_size(true).0
+ 2 * self.core.applet.suggested_padding(true))
as f32
))
)
.align_items(Alignment::Center),
)
})
.padding(if horizontal {
2024-04-15 18:37:00 -04:00
[0, self.core.applet.suggested_padding(true)]
} else {
2024-04-15 18:37:00 -04:00
[self.core.applet.suggested_padding(true), 0]
})
2024-08-13 19:03:34 +02:00
.on_press_down(Message::TogglePopup)
2023-10-24 16:33:00 -05:00
.style(cosmic::theme::Button::AppletIcon);
if let Some(tracker) = self.rectangle_tracker.as_ref() {
tracker.container(0, button).ignore_bounds(true).into()
} else {
button.into()
}
}
fn view_window(&self, _id: window::Id) -> Element<Message> {
2024-05-25 05:16:25 +02:00
let mut date_bag = Bag::empty();
date_bag.month = Some(components::Month::Long);
date_bag.day = Some(components::Day::NumericDayOfMonth);
date_bag.year = Some(components::Year::Numeric);
let date = text(self.format(date_bag, &self.date_selected)).size(18);
let mut day_of_week_bag = Bag::empty();
day_of_week_bag.weekday = Some(components::Text::Long);
let day_of_week = text(self.format(day_of_week_bag, &self.date_selected)).size(14);
let month_controls = row![
button::icon(icon::from_name("go-previous-symbolic"))
.padding(8)
.on_press(Message::PreviousMonth),
button::icon(icon::from_name("go-next-symbolic"))
.padding(8)
.on_press(Message::NextMonth)
]
.spacing(8);
// Calender
2024-05-25 05:16:25 +02:00
let mut calender: Grid<'_, Message> = grid().width(Length::Fill);
2024-05-25 05:16:25 +02:00
let mut first_day_of_week = chrono::Weekday::try_from(self.config.first_day_of_week)
.unwrap_or(chrono::Weekday::Sun);
let first_day = get_calender_first(
self.date_selected.year(),
self.date_selected.month(),
first_day_of_week,
);
let mut weekday_bag = Bag::empty();
weekday_bag.weekday = Some(components::Text::Short);
let mut day_iter = first_day.iter_days();
for _ in 0..7 {
calender = calender.push(
2024-05-25 05:16:25 +02:00
text(self.format(weekday_bag, &day_iter.next().unwrap()))
.size(12)
.width(Length::Fixed(36.0))
.horizontal_alignment(Horizontal::Center),
);
first_day_of_week = first_day_of_week.succ();
}
calender = calender.insert_row();
2024-05-25 05:16:25 +02:00
let mut day_iter = first_day.iter_days();
2024-02-26 10:24:07 -08:00
for i in 0..42 {
if i > 0 && i % 7 == 0 {
calender = calender.insert_row();
}
let date = day_iter.next().unwrap();
2024-02-26 10:24:07 -08:00
let is_month = date.month() == self.date_selected.month()
&& date.year_ce() == self.date_selected.year_ce();
let is_day = date.day() == self.date_selected.day() && is_month;
calender = calender.push(date_button(date.day(), is_month, is_day));
}
// content
let content_list = column![
row![
column![date, day_of_week],
Space::with_width(Length::Fill),
month_controls,
]
.align_items(Alignment::Center)
.padding([12, 20]),
calender.padding([0, 12].into()),
2023-10-24 16:33:00 -05:00
padded_control(divider::horizontal::default()),
menu_button(text(fl!("datetime-settings")).size(14))
.on_press(Message::OpenDateTimeSettings),
]
.padding([8, 0]);
self.core
.applet
.popup_container(container(content_list))
.into()
}
fn on_close_requested(&self, id: window::Id) -> Option<Message> {
Some(Message::CloseRequested(id))
}
}
2024-05-17 20:01:40 -04:00
fn date_button(day: u32, is_month: bool, is_day: bool) -> Button<'static, Message> {
let style = if is_day {
cosmic::widget::button::Style::Suggested
} else {
cosmic::widget::button::Style::Text
};
let button = button(
text(format!("{day}"))
.size(14.0)
.horizontal_alignment(Horizontal::Center)
.vertical_alignment(Vertical::Center),
)
.style(style)
.height(Length::Fixed(36.0))
.width(Length::Fixed(36.0));
if is_month {
button.on_press(Message::SelectDay(day))
} else {
button
}
}