647 lines
24 KiB
Rust
647 lines
24 KiB
Rust
mod backend;
|
|
|
|
use self::backend::{GetCurrentPowerProfile, SetPowerProfile};
|
|
use backend::{Battery, ConnectedDevice, PowerProfile};
|
|
|
|
use cosmic::iced::core::text::{Ellipsize, EllipsizeHeightLimit};
|
|
use cosmic::iced::widget::{column, row};
|
|
use cosmic::iced::{self, Alignment, Length, stream};
|
|
use cosmic::widget::{self, settings, space, text};
|
|
use cosmic::{Apply, Task, surface};
|
|
use cosmic_config::{Config, CosmicConfigEntry};
|
|
use cosmic_idle_config::CosmicIdleConfig;
|
|
use cosmic_settings_page::{self as page, Section, section};
|
|
use futures::{SinkExt, StreamExt};
|
|
use itertools::Itertools;
|
|
use slab::Slab;
|
|
use slotmap::SlotMap;
|
|
use std::hash::Hash;
|
|
use std::iter;
|
|
use std::time::Duration;
|
|
use upower_dbus::DeviceProxy;
|
|
|
|
static SCREEN_OFF_TIMES: &[Duration] = &[
|
|
Duration::from_secs(2 * 60),
|
|
Duration::from_secs(5 * 60),
|
|
Duration::from_secs(10 * 60),
|
|
Duration::from_secs(15 * 60),
|
|
Duration::from_secs(30 * 60),
|
|
];
|
|
|
|
static SUSPEND_TIMES: &[Duration] = &[
|
|
Duration::from_secs(15 * 60),
|
|
Duration::from_secs(20 * 60),
|
|
Duration::from_secs(25 * 60),
|
|
Duration::from_secs(30 * 60),
|
|
Duration::from_secs(45 * 60),
|
|
Duration::from_secs(60 * 60),
|
|
Duration::from_secs(80 * 60),
|
|
Duration::from_secs(90 * 60),
|
|
Duration::from_secs(100 * 60),
|
|
Duration::from_secs(2 * 60 * 60),
|
|
];
|
|
|
|
fn format_time(duration: Duration) -> String {
|
|
let m = duration.as_secs() / 60;
|
|
if m.is_multiple_of(60) {
|
|
fl!("x-hours", number = (m / 60))
|
|
} else {
|
|
fl!("x-minutes", number = m)
|
|
}
|
|
}
|
|
|
|
pub struct Page {
|
|
entity: page::Entity,
|
|
battery: Battery,
|
|
connected_devices: Vec<ConnectedDevice>,
|
|
on_enter_handle: Option<cosmic::iced::task::Handle>,
|
|
screen_off_labels: Vec<String>,
|
|
suspend_labels: Vec<String>,
|
|
idle_config: Config,
|
|
idle_conf: CosmicIdleConfig,
|
|
backend: Option<backend::PowerBackendEnum>,
|
|
current_power_profile: Option<PowerProfile>,
|
|
}
|
|
|
|
impl Default for Page {
|
|
fn default() -> Self {
|
|
let idle_config = Config::new("com.system76.CosmicIdle", 1).unwrap();
|
|
let idle_conf = CosmicIdleConfig::get_entry(&idle_config).unwrap_or_else(|(_, conf)| conf);
|
|
|
|
Self {
|
|
entity: Default::default(),
|
|
battery: Default::default(),
|
|
connected_devices: Vec::new(),
|
|
on_enter_handle: None,
|
|
screen_off_labels: SCREEN_OFF_TIMES
|
|
.iter()
|
|
.copied()
|
|
.map(format_time)
|
|
.chain(iter::once(fl!("never")))
|
|
.collect(),
|
|
suspend_labels: SUSPEND_TIMES
|
|
.iter()
|
|
.copied()
|
|
.map(format_time)
|
|
.chain(iter::once(fl!("never")))
|
|
.collect(),
|
|
idle_config,
|
|
idle_conf,
|
|
backend: None,
|
|
current_power_profile: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl page::Page<crate::pages::Message> for Page {
|
|
fn set_id(&mut self, entity: page::Entity) {
|
|
self.entity = entity;
|
|
}
|
|
|
|
fn info(&self) -> page::Info {
|
|
page::Info::new("power", "preferences-power-and-battery-symbolic")
|
|
.title(fl!("power"))
|
|
.description(fl!("xdg-entry-power-comment"))
|
|
}
|
|
|
|
fn content(
|
|
&self,
|
|
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
|
|
) -> Option<page::Content> {
|
|
Some(vec![
|
|
sections.insert(battery_info()),
|
|
sections.insert(connected_devices()),
|
|
sections.insert(profiles()),
|
|
sections.insert(power_saving()),
|
|
])
|
|
}
|
|
|
|
fn subscription(
|
|
&self,
|
|
_core: &cosmic::Core,
|
|
) -> cosmic::iced::Subscription<crate::pages::Message> {
|
|
// Shared logic between the system battery and connected device batteries.
|
|
async fn receive_battery_changes(
|
|
proxy: DeviceProxy<'static>,
|
|
device_path: String,
|
|
mut sender: futures::channel::mpsc::Sender<crate::pages::Message>,
|
|
message_fn: fn(String, Battery) -> Message,
|
|
) {
|
|
let mut battery_level = proxy.receive_battery_level_changed().await;
|
|
let mut battery_state = proxy.receive_state_changed().await;
|
|
|
|
loop {
|
|
let _ = futures::future::select(battery_level.next(), battery_state.next()).await;
|
|
|
|
_ = sender
|
|
.send(
|
|
message_fn(device_path.clone(), Battery::from_device(&proxy).await).into(),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
// A subscription for the system battery.
|
|
let system_battery = iced::Subscription::run(|| {
|
|
stream::channel(1, |sender| async move {
|
|
if let Ok(proxy) = backend::get_device_proxy().await {
|
|
receive_battery_changes(proxy, String::new(), sender, |_, b| {
|
|
Message::UpdateBattery(b)
|
|
})
|
|
.await;
|
|
}
|
|
})
|
|
});
|
|
|
|
// Subscriptions for all connected device batteries.
|
|
let device_batteries =
|
|
self.connected_devices
|
|
.iter()
|
|
.filter_map(|device| {
|
|
device
|
|
.proxy
|
|
.clone()
|
|
.map(|p| (device.device_path.clone(), p))
|
|
})
|
|
.map(|(path, proxy)| {
|
|
#[derive(Clone)]
|
|
struct DeviceBatterySubscriptionData {
|
|
proxy: DeviceProxy<'static>,
|
|
path: String,
|
|
}
|
|
|
|
impl Hash for DeviceBatterySubscriptionData {
|
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
self.path.hash(state);
|
|
}
|
|
}
|
|
|
|
iced::Subscription::run_with(
|
|
DeviceBatterySubscriptionData { proxy, path },
|
|
|DeviceBatterySubscriptionData { proxy, path }| {
|
|
let path = path.clone();
|
|
let proxy = proxy.clone();
|
|
stream::channel(
|
|
1,
|
|
move |sender: futures::channel::mpsc::Sender<
|
|
crate::pages::Message,
|
|
>| async move {
|
|
receive_battery_changes(
|
|
proxy,
|
|
path,
|
|
sender,
|
|
Message::UpdateDeviceBattery,
|
|
)
|
|
.await
|
|
},
|
|
)
|
|
},
|
|
)
|
|
});
|
|
|
|
iced::Subscription::batch(std::iter::once(system_battery).chain(device_batteries))
|
|
}
|
|
|
|
fn on_enter(&mut self) -> cosmic::Task<crate::pages::Message> {
|
|
let futures: Vec<Task<Message>> = vec![
|
|
cosmic::Task::future(async move {
|
|
let battery = Battery::update_battery().await;
|
|
Message::UpdateBattery(battery)
|
|
}),
|
|
cosmic::Task::future(async move {
|
|
let devices = ConnectedDevice::update_connected_devices().await;
|
|
Message::UpdateConnectedDevices(devices)
|
|
}),
|
|
cosmic::Task::future(async move {
|
|
if let Ok(backend) = tokio::time::timeout(
|
|
std::time::Duration::from_millis(1000),
|
|
backend::get_backend(),
|
|
)
|
|
.await
|
|
{
|
|
Message::BackendAvailabilityCheck(backend)
|
|
} else {
|
|
tracing::warn!("Power backend initialization timed out after 1000ms");
|
|
Message::BackendAvailabilityCheck(None)
|
|
}
|
|
}),
|
|
cosmic::Task::run(
|
|
stream::channel(
|
|
1,
|
|
|mut emitter: futures::channel::mpsc::Sender<Message>| async move {
|
|
let span =
|
|
tracing::span!(tracing::Level::INFO, "power::device_stream task");
|
|
let _span_handle = span.enter();
|
|
|
|
let Ok(connection) = zbus::Connection::system().await else {
|
|
tracing::error!("could not established zbus connection to system");
|
|
return;
|
|
};
|
|
|
|
let added_stream = ConnectedDevice::device_added_stream(&connection).await;
|
|
let removed_stream =
|
|
ConnectedDevice::device_removed_stream(&connection).await;
|
|
|
|
let mut sender = emitter.clone();
|
|
let added_future = std::pin::pin!(async {
|
|
match added_stream {
|
|
Ok(stream) => {
|
|
let mut stream = std::pin::pin!(stream);
|
|
while let Some(device) = stream.next().await {
|
|
tracing::debug!(device = device.model, "device added");
|
|
_ = sender.send(Message::DeviceConnect(device)).await;
|
|
}
|
|
}
|
|
Err(err) => tracing::error!(?err, "cannot establish added stream"),
|
|
}
|
|
});
|
|
|
|
let removed_future = std::pin::pin!(async {
|
|
match removed_stream {
|
|
Ok(stream) => {
|
|
let mut stream = std::pin::pin!(stream);
|
|
while let Some(device_path) = stream.next().await {
|
|
tracing::debug!(device_path, "device removed");
|
|
_ = emitter
|
|
.send(Message::DeviceDisconnect(device_path))
|
|
.await;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(?err, "cannot establish removed stream")
|
|
}
|
|
}
|
|
});
|
|
|
|
futures::future::select(added_future, removed_future).await;
|
|
},
|
|
),
|
|
|msg| msg,
|
|
),
|
|
];
|
|
|
|
let (task, handle) = cosmic::Task::batch(futures)
|
|
.map(crate::pages::Message::Power)
|
|
.abortable();
|
|
|
|
self.on_enter_handle = Some(handle);
|
|
task
|
|
}
|
|
|
|
fn on_leave(&mut self) -> Task<crate::pages::Message> {
|
|
if let Some(handle) = self.on_enter_handle.take() {
|
|
handle.abort();
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum Message {
|
|
PowerProfileChange(PowerProfile),
|
|
/// Update the system battery
|
|
UpdateBattery(Battery),
|
|
/// Update the battery of a connected device
|
|
UpdateDeviceBattery(String, Battery),
|
|
UpdateConnectedDevices(Vec<ConnectedDevice>),
|
|
DeviceDisconnect(String),
|
|
DeviceConnect(ConnectedDevice),
|
|
ScreenOffTimeChange(Option<Duration>),
|
|
SuspendOnAcTimeChange(Option<Duration>),
|
|
SuspendOnBatteryTimeChange(Option<Duration>),
|
|
BackendAvailabilityCheck(Option<backend::PowerBackendEnum>),
|
|
CurrentPowerProfileUpdate(PowerProfile),
|
|
Surface(surface::Action),
|
|
}
|
|
|
|
impl From<Message> for crate::app::Message {
|
|
fn from(message: Message) -> Self {
|
|
crate::pages::Message::Power(message).into()
|
|
}
|
|
}
|
|
|
|
impl From<Message> for crate::pages::Message {
|
|
fn from(message: Message) -> Self {
|
|
crate::pages::Message::Power(message)
|
|
}
|
|
}
|
|
|
|
impl Page {
|
|
pub fn update(&mut self, message: Message) -> Task<crate::app::Message> {
|
|
match message {
|
|
Message::PowerProfileChange(p) => {
|
|
if let Some(ref backend) = self.backend {
|
|
self.current_power_profile = Some(p);
|
|
let backend = backend.clone();
|
|
return cosmic::Task::future(async move {
|
|
backend.set_power_profile(p).await;
|
|
crate::app::Message::None
|
|
});
|
|
}
|
|
}
|
|
Message::UpdateBattery(battery) => self.battery = battery,
|
|
Message::UpdateDeviceBattery(path, battery) => {
|
|
for device in &mut self.connected_devices {
|
|
if device.device_path == path {
|
|
device.battery = battery;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Message::UpdateConnectedDevices(connected_devices) => {
|
|
self.connected_devices = connected_devices;
|
|
}
|
|
Message::ScreenOffTimeChange(time) => {
|
|
let time = time.map(|x| x.as_millis() as u32);
|
|
if let Err(err) = self.idle_conf.set_screen_off_time(&self.idle_config, time) {
|
|
tracing::error!("failed to set screen off time: {}", err)
|
|
}
|
|
}
|
|
Message::SuspendOnAcTimeChange(time) => {
|
|
let time = time.map(|x| x.as_millis() as u32);
|
|
if let Err(err) = self
|
|
.idle_conf
|
|
.set_suspend_on_ac_time(&self.idle_config, time)
|
|
{
|
|
tracing::error!("failed to set suspend on ac time: {}", err)
|
|
}
|
|
}
|
|
Message::SuspendOnBatteryTimeChange(time) => {
|
|
let time = time.map(|x| x.as_millis() as u32);
|
|
if let Err(err) = self
|
|
.idle_conf
|
|
.set_suspend_on_battery_time(&self.idle_config, time)
|
|
{
|
|
tracing::error!("failed to set suspend on battery time: {}", err)
|
|
}
|
|
}
|
|
Message::DeviceDisconnect(device_path) => self
|
|
.connected_devices
|
|
.retain(|device| device.device_path != device_path),
|
|
Message::DeviceConnect(new_device) => {
|
|
// If a connected device already exists at a path, replace it.
|
|
if let Some(old) = self
|
|
.connected_devices
|
|
.iter_mut()
|
|
.find(|existing| existing.device_path == new_device.device_path)
|
|
{
|
|
*old = new_device;
|
|
} else {
|
|
self.connected_devices.push(new_device)
|
|
}
|
|
}
|
|
Message::Surface(a) => {
|
|
return cosmic::task::message(crate::app::Message::Surface(a));
|
|
}
|
|
Message::BackendAvailabilityCheck(backend) => {
|
|
self.backend.clone_from(&backend);
|
|
|
|
// If backend is available, get the current power profile
|
|
if let Some(backend) = backend {
|
|
return cosmic::Task::future(async move {
|
|
let profile = backend.get_current_power_profile().await;
|
|
crate::app::Message::PageMessage(crate::pages::Message::Power(
|
|
Message::CurrentPowerProfileUpdate(profile),
|
|
))
|
|
});
|
|
}
|
|
}
|
|
Message::CurrentPowerProfileUpdate(profile) => {
|
|
self.current_power_profile = Some(profile);
|
|
}
|
|
};
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
fn battery_info() -> Section<crate::pages::Message> {
|
|
let descriptions = Slab::new();
|
|
|
|
Section::default()
|
|
.title(fl!("battery"))
|
|
.descriptions(descriptions)
|
|
.show_while::<Page>(|page| page.battery.is_present)
|
|
.view::<Page>(move |_binder, page, section| {
|
|
let battery_icon = widget::icon::from_name(page.battery.icon_name.clone());
|
|
let remaining_time = page.battery.remaining_time();
|
|
let battery_label = text::body(if remaining_time.is_empty() {
|
|
format!("{:.0}%", page.battery.percent)
|
|
} else {
|
|
format!("{:.0}% ({})", page.battery.percent, remaining_time)
|
|
});
|
|
|
|
widget::column::with_capacity(2)
|
|
.push(text::heading(§ion.title))
|
|
.push(
|
|
row!(battery_icon, battery_label)
|
|
.align_y(Alignment::Center)
|
|
.spacing(cosmic::theme::active().cosmic().space_xxxs()),
|
|
)
|
|
.into()
|
|
})
|
|
}
|
|
|
|
fn connected_devices() -> Section<crate::pages::Message> {
|
|
let descriptions = Slab::new();
|
|
|
|
Section::default()
|
|
.title(fl!("connected-devices"))
|
|
.descriptions(descriptions)
|
|
.show_while::<Page>(|page| !page.connected_devices.is_empty())
|
|
.view::<Page>(move |_binder, page, section| {
|
|
let devices: Vec<cosmic::Element<'_, _>> = page
|
|
.connected_devices
|
|
.iter()
|
|
.map(|connected_device| {
|
|
let battery_icon =
|
|
widget::icon::from_name(connected_device.battery.icon_name.clone());
|
|
|
|
let battery_percent_and_time = widget::text(
|
|
if connected_device.battery.remaining_duration.is_positive() {
|
|
format!(
|
|
"{}% - {}",
|
|
connected_device.battery.percent,
|
|
&connected_device.battery.remaining_time()
|
|
)
|
|
} else {
|
|
format!("{}%", connected_device.battery.percent)
|
|
},
|
|
);
|
|
widget::container(
|
|
row!(
|
|
widget::icon::from_name(connected_device.device_icon).size(48),
|
|
column!(
|
|
text::heading(&connected_device.model)
|
|
.ellipsize(Ellipsize::End(EllipsizeHeightLimit::Lines(1)))
|
|
.height(20.0)
|
|
.width(Length::Fill),
|
|
row!(battery_icon, battery_percent_and_time)
|
|
.spacing(4)
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.height(Length::Shrink)
|
|
)
|
|
.align_y(Alignment::Center)
|
|
.spacing(16)
|
|
.padding([8, 16])
|
|
.width(Length::Fill)
|
|
.height(Length::Fill),
|
|
)
|
|
.height(64.)
|
|
.class(cosmic::theme::Container::List)
|
|
.into()
|
|
})
|
|
.collect();
|
|
|
|
widget::column::with_capacity(2)
|
|
.spacing(8)
|
|
.push(text::heading(§ion.title))
|
|
.push(
|
|
widget::column::with_capacity(devices.len())
|
|
.extend(
|
|
devices
|
|
.into_iter()
|
|
.chunks(2)
|
|
.into_iter()
|
|
.map(|mut device_row| {
|
|
cosmic::Element::from(
|
|
row!(
|
|
device_row.next().unwrap_or(space::horizontal().into()),
|
|
device_row.next().unwrap_or(space::horizontal().into()),
|
|
)
|
|
.spacing(8),
|
|
)
|
|
}),
|
|
)
|
|
.spacing(8),
|
|
)
|
|
.into()
|
|
})
|
|
}
|
|
|
|
fn profiles() -> Section<crate::pages::Message> {
|
|
crate::slab!(descriptions {
|
|
_power_desc = fl!("xdg-entry-power-comment");
|
|
});
|
|
|
|
Section::default()
|
|
.title(fl!("power-mode"))
|
|
.descriptions(descriptions)
|
|
.view::<Page>(move |_binder, page, section| {
|
|
let mut section = settings::section().title(§ion.title);
|
|
|
|
if page.backend.is_some() {
|
|
let profiles = backend::get_power_profiles();
|
|
|
|
if let Some(current_profile) = page.current_power_profile {
|
|
section = profiles
|
|
.into_iter()
|
|
.map(|profile| {
|
|
settings::item::builder(profile.title())
|
|
.description(profile.description())
|
|
.radio(profile, Some(current_profile), Message::PowerProfileChange)
|
|
})
|
|
.fold(section, settings::Section::add);
|
|
}
|
|
} else {
|
|
let item = text::body(fl!("power-mode", "no-backend"));
|
|
section = section.add(item);
|
|
}
|
|
|
|
section
|
|
.apply(cosmic::Element::from)
|
|
.map(crate::pages::Message::Power)
|
|
})
|
|
}
|
|
|
|
fn power_saving_row<'a>(
|
|
label: &'a str,
|
|
labels: &'a [String],
|
|
selected_time: Option<Duration>,
|
|
times: &'static [Duration],
|
|
on_select: fn(Option<Duration>) -> Message,
|
|
) -> cosmic::Element<'a, Message> {
|
|
let selected = if let Some(time) = selected_time {
|
|
times.iter().position(|x| *x == time)
|
|
} else {
|
|
// "Never"
|
|
Some(times.len())
|
|
};
|
|
|
|
settings::item(
|
|
label,
|
|
widget::dropdown::popup_dropdown(
|
|
labels,
|
|
selected,
|
|
move |i| on_select(times.get(i).copied()),
|
|
cosmic::iced::window::Id::RESERVED,
|
|
Message::Surface,
|
|
|a| crate::app::Message::PageMessage(crate::pages::Message::Power(a)),
|
|
),
|
|
)
|
|
.into()
|
|
}
|
|
|
|
fn power_saving() -> Section<crate::pages::Message> {
|
|
let mut descriptions = Slab::new();
|
|
|
|
let turn_off_screen_desc = descriptions.insert(fl!("power-saving", "turn-off-screen-after"));
|
|
let auto_suspend_desc = descriptions.insert(fl!("power-saving", "auto-suspend"));
|
|
let auto_suspend_ac_desc = descriptions.insert(fl!("power-saving", "auto-suspend-ac"));
|
|
let auto_suspend_battery_desc =
|
|
descriptions.insert(fl!("power-saving", "auto-suspend-battery"));
|
|
|
|
Section::default()
|
|
.title(fl!("power-saving"))
|
|
.descriptions(descriptions)
|
|
.view::<Page>(move |_binder, page, section| {
|
|
let descriptions = §ion.descriptions;
|
|
let screen_off_time = page
|
|
.idle_conf
|
|
.screen_off_time
|
|
.map(|t| Duration::from_millis(t.into()));
|
|
let suspend_on_ac_time = page
|
|
.idle_conf
|
|
.suspend_on_ac_time
|
|
.map(|t| Duration::from_millis(t.into()));
|
|
let suspend_on_battery_time = page
|
|
.idle_conf
|
|
.suspend_on_battery_time
|
|
.map(|t| Duration::from_millis(t.into()));
|
|
let mut section = settings::section()
|
|
.title(§ion.title)
|
|
.add(power_saving_row(
|
|
&descriptions[turn_off_screen_desc],
|
|
&page.screen_off_labels,
|
|
screen_off_time,
|
|
SCREEN_OFF_TIMES,
|
|
Message::ScreenOffTimeChange,
|
|
))
|
|
.add(power_saving_row(
|
|
&descriptions[if page.battery.is_present {
|
|
auto_suspend_ac_desc
|
|
} else {
|
|
auto_suspend_desc
|
|
}],
|
|
&page.suspend_labels,
|
|
suspend_on_ac_time,
|
|
SUSPEND_TIMES,
|
|
Message::SuspendOnAcTimeChange,
|
|
));
|
|
if page.battery.is_present {
|
|
section = section.add(power_saving_row(
|
|
&descriptions[auto_suspend_battery_desc],
|
|
&page.suspend_labels,
|
|
suspend_on_battery_time,
|
|
SUSPEND_TIMES,
|
|
Message::SuspendOnBatteryTimeChange,
|
|
));
|
|
}
|
|
section
|
|
.apply(cosmic::Element::from)
|
|
.map(crate::pages::Message::Power)
|
|
})
|
|
}
|
|
|
|
impl page::AutoBind<crate::pages::Message> for Page {}
|