1175 lines
42 KiB
Rust
1175 lines
42 KiB
Rust
use super::*;
|
|
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
|
|
use cosmic::iced::{Alignment, Rectangle};
|
|
use cosmic::widget::{column, slider};
|
|
use status::{Action, Snapshot};
|
|
|
|
const DEVICE_LIST_HEIGHT: f32 = 180.0;
|
|
const OPEN_MS: f32 = 160.0;
|
|
|
|
fn device_list_height(count: usize) -> Option<f32> {
|
|
(count > 4).then_some(DEVICE_LIST_HEIGHT)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Menu {
|
|
Network,
|
|
Bluetooth,
|
|
Audio,
|
|
Battery,
|
|
Notifications,
|
|
System,
|
|
}
|
|
impl Menu {
|
|
fn width(self) -> f32 {
|
|
match self {
|
|
Self::System => 320.0,
|
|
Self::Network | Self::Bluetooth => 280.0,
|
|
Self::Battery => 250.0,
|
|
Self::Audio | Self::Notifications => 290.0,
|
|
}
|
|
}
|
|
pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
|
|
// Popovers contain independent cards, with no shared material backing.
|
|
None
|
|
}
|
|
fn available(self, status: &Snapshot) -> bool {
|
|
match self {
|
|
Self::Network => status.network.is_some(),
|
|
Self::Bluetooth => status.bluetooth.is_some(),
|
|
Self::Audio => status.audio.is_some(),
|
|
Self::Battery => status.battery.is_some(),
|
|
Self::Notifications => status.notifications.is_some(),
|
|
Self::System => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct OpenMenu {
|
|
pub id: window::Id,
|
|
pub kind: Menu,
|
|
opened: Instant,
|
|
pub confirm: Option<Action>,
|
|
pub effects: Option<EffectsBinding>,
|
|
pub regions: super::motion::Regions,
|
|
}
|
|
impl OpenMenu {
|
|
pub fn progress(&self) -> f32 {
|
|
let ease = |t: f32| {
|
|
let t = t.clamp(0.0, 1.0);
|
|
1.0 - (1.0 - t).powi(3)
|
|
};
|
|
ease(self.opened.elapsed().as_secs_f32() * 1000.0 / OPEN_MS)
|
|
}
|
|
pub fn animating(&self) -> bool {
|
|
self.progress() < 1.0
|
|
}
|
|
}
|
|
|
|
impl FereseShell {
|
|
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
|
|
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
|
|
// Close atomically. Per-widget alpha cannot fade SVG/text caches
|
|
// as one surface and leaves bright fragments during the fade.
|
|
return self.destroy_menu();
|
|
}
|
|
let destroy = self.destroy_menu();
|
|
if !kind.available(&self.status) {
|
|
return destroy;
|
|
}
|
|
let id = window::Id::unique();
|
|
self.menu = Some(OpenMenu {
|
|
id,
|
|
kind,
|
|
opened: Instant::now(),
|
|
confirm: None,
|
|
effects: None,
|
|
regions: Default::default(),
|
|
});
|
|
super::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
self.status_error = None;
|
|
let parent = self.bar_surface_id;
|
|
let action = cosmic::surface::action::app_popup::<Self>(
|
|
|_| Default::default(),
|
|
move |_| SctkPopupSettings {
|
|
id,
|
|
parent,
|
|
parent_size: None,
|
|
grab: true,
|
|
close_with_children: false,
|
|
input_zone: None,
|
|
positioner: SctkPositioner {
|
|
anchor_rect: anchor,
|
|
// Numeric protocol values avoid depending on libcosmic's private SCTK reexport.
|
|
anchor: 2u32.try_into().unwrap(), // bottom
|
|
gravity: 6u32.try_into().unwrap(), // bottom-left
|
|
offset: (anchor.width / 2, 4),
|
|
size_limits: Limits::NONE
|
|
.min_width(kind.width())
|
|
.max_width(kind.width())
|
|
.max_height(720.0),
|
|
constraint_adjustment: 3, // slide X/Y, never flip above the bar
|
|
..Default::default()
|
|
},
|
|
},
|
|
Some(Box::new(Self::view_status_menu)),
|
|
);
|
|
destroy.chain(cosmic::task::message(cosmic::Action::Surface(action)))
|
|
}
|
|
pub fn destroy_menu(&mut self) -> Task<Message> {
|
|
super::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
self.menu.take().map_or_else(Task::none, |menu| {
|
|
cosmic::task::message(cosmic::Action::Surface(
|
|
cosmic::surface::action::destroy_popup(menu.id),
|
|
))
|
|
})
|
|
}
|
|
pub fn optimistic_status(&mut self, action: &Action) {
|
|
match action {
|
|
Action::Volume(v) => {
|
|
if let Some(a) = &mut self.status.audio {
|
|
a.volume = *v;
|
|
}
|
|
}
|
|
Action::Mute(v) => {
|
|
if let Some(a) = &mut self.status.audio {
|
|
a.muted = *v;
|
|
}
|
|
}
|
|
Action::Brightness(v) => self.status.brightness = Some(*v),
|
|
Action::Wifi(v) => {
|
|
if let Some(n) = &mut self.status.network {
|
|
n.enabled = *v;
|
|
}
|
|
}
|
|
Action::Bluetooth(v) => {
|
|
if let Some(b) = &mut self.status.bluetooth {
|
|
b.enabled = *v;
|
|
}
|
|
}
|
|
Action::Dnd(v) => {
|
|
if let Some(n) = &mut self.status.notifications {
|
|
n.dnd = *v;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
|
|
let theme = self.config.theme.for_bar();
|
|
let metrics = BarMetrics::from(theme);
|
|
let mut controls = row::with_capacity(6).spacing(1).align_y(Alignment::Center);
|
|
for kind in [
|
|
Menu::System,
|
|
Menu::Network,
|
|
Menu::Bluetooth,
|
|
Menu::Audio,
|
|
Menu::Notifications,
|
|
Menu::Battery,
|
|
] {
|
|
if !kind.available(&self.status) {
|
|
continue;
|
|
}
|
|
let (source, _) = status_icon(kind, &self.status);
|
|
let foreground = color(theme.text_primary);
|
|
let mut content = row![accented_icon(
|
|
source,
|
|
metrics.icon_size,
|
|
foreground,
|
|
color(theme.accent)
|
|
)]
|
|
.spacing(4)
|
|
.align_y(Alignment::Center);
|
|
if kind == Menu::Battery && self.config.status.battery_percentage {
|
|
if let Some(battery) = &self.status.battery {
|
|
content = content.push(
|
|
text(format!("{}%", battery.percent))
|
|
.size(metrics.text_size)
|
|
.class(theme::Text::Color(foreground)),
|
|
);
|
|
}
|
|
}
|
|
if kind == Menu::Notifications
|
|
&& self
|
|
.status
|
|
.notifications
|
|
.as_ref()
|
|
.is_some_and(|n| n.count > 0 && !n.dnd)
|
|
{
|
|
content = content.push(container(text("")).width(4).height(4).class(
|
|
theme::Container::custom(move |_| container::Style {
|
|
background: Some(Background::Color(foreground)),
|
|
border: Border {
|
|
radius: 2.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}),
|
|
));
|
|
}
|
|
let selected = self.menu.as_ref().is_some_and(|m| m.kind == kind);
|
|
// A fixed button height does not center its child in libcosmic.
|
|
// Keep the visual content centered inside the entire click target.
|
|
let content = container(content)
|
|
.align_x(alignment::Horizontal::Center)
|
|
.height(metrics.height)
|
|
.align_y(alignment::Vertical::Center);
|
|
let control = button::custom(content)
|
|
.name(status_label(kind, &self.status))
|
|
.padding([
|
|
0.0,
|
|
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
|
|
])
|
|
.height(metrics.height)
|
|
.class(button_style(foreground, selected, 1.0))
|
|
.on_press_with_rectangle(move |offset, bounds| {
|
|
cosmic::Action::App(Message::OpenMenu(
|
|
kind,
|
|
Rectangle {
|
|
x: (bounds.x - offset.x).round() as i32,
|
|
y: (bounds.y - offset.y).round() as i32,
|
|
width: bounds.width.round() as i32,
|
|
height: bounds.height.round() as i32,
|
|
},
|
|
))
|
|
});
|
|
controls = controls.push(control);
|
|
}
|
|
controls.into()
|
|
}
|
|
|
|
fn view_status_menu(&self) -> Element<'_, cosmic::Action<Message>> {
|
|
let Some(menu) = &self.menu else {
|
|
return text("").into();
|
|
};
|
|
let theme = self.config.theme;
|
|
let p = menu.progress();
|
|
let kind = menu.kind;
|
|
let primary = color_with_opacity(theme.text_primary, p);
|
|
let muted = color_with_opacity(theme.text_muted, p);
|
|
let mut rows = column::with_capacity(12).spacing(8).width(Length::Fill);
|
|
if let Some(action) = &menu.confirm {
|
|
let title = match action {
|
|
Action::Poweroff => "Power off this computer?",
|
|
Action::Suspend => "Suspend this computer?",
|
|
_ => "Restart this computer?",
|
|
};
|
|
rows = rows
|
|
.push(text(title))
|
|
.push(
|
|
text(if matches!(action, Action::Suspend) {
|
|
"The session will not be locked. Anyone can access it after waking."
|
|
} else {
|
|
"Save your work before continuing."
|
|
})
|
|
.size(13),
|
|
)
|
|
.push(
|
|
row![
|
|
menu_button("Cancel", Message::CancelPower, primary, p),
|
|
menu_button("Confirm", Message::Control(action.clone()), primary, p)
|
|
]
|
|
.spacing(8),
|
|
);
|
|
} else {
|
|
let combined = menu.kind == Menu::System;
|
|
if combined {
|
|
let mut connections = row::with_capacity(3).spacing(8).width(Length::Fill);
|
|
if let Some(n) = &self.status.network {
|
|
connections = connections.push(connection_control(
|
|
"Wi-Fi",
|
|
if n.enabled {
|
|
n.connection.as_deref().unwrap_or("Not connected")
|
|
} else {
|
|
"Off"
|
|
},
|
|
status_icon(Menu::Network, &self.status).0,
|
|
n.enabled,
|
|
Some(Action::Wifi(!n.enabled)),
|
|
theme,
|
|
p,
|
|
));
|
|
}
|
|
if let Some(b) = &self.status.bluetooth {
|
|
connections = connections.push(connection_control(
|
|
"Bluetooth",
|
|
if b.enabled {
|
|
b.devices.first().map(String::as_str).unwrap_or("On")
|
|
} else {
|
|
"Off"
|
|
},
|
|
status_icon(Menu::Bluetooth, &self.status).0,
|
|
b.enabled,
|
|
Some(Action::Bluetooth(!b.enabled)),
|
|
theme,
|
|
p,
|
|
));
|
|
}
|
|
if let Some(b) = &self.status.battery {
|
|
connections = connections.push(connection_control(
|
|
"Battery",
|
|
&format!("{}%", b.percent),
|
|
status_icon(Menu::Battery, &self.status).0,
|
|
b.status == "Charging",
|
|
None,
|
|
theme,
|
|
p,
|
|
));
|
|
}
|
|
if self.status.network.is_some()
|
|
|| self.status.bluetooth.is_some()
|
|
|| self.status.battery.is_some()
|
|
{
|
|
rows = rows.push(control_card(connections.into(), primary, p));
|
|
}
|
|
}
|
|
if menu.kind == Menu::Network {
|
|
if let Some(n) = &self.status.network {
|
|
let connected = n.enabled && n.connection.is_some();
|
|
rows = rows.push(status_summary(
|
|
status_icon(Menu::Network, &self.status).0,
|
|
if !n.enabled {
|
|
"Wi-Fi is off"
|
|
} else {
|
|
n.connection.as_deref().unwrap_or("Not connected")
|
|
},
|
|
if connected {
|
|
"Connected"
|
|
} else if n.enabled {
|
|
"No active wireless connection"
|
|
} else {
|
|
"Wireless connections are paused"
|
|
},
|
|
primary,
|
|
muted,
|
|
color_with_opacity(theme.accent, p),
|
|
p,
|
|
Some((n.enabled, Action::Wifi(!n.enabled))),
|
|
));
|
|
if connected {
|
|
let strength = match n.signal {
|
|
75.. => "Excellent",
|
|
50..=74 => "Good",
|
|
25..=49 => "Fair",
|
|
_ => "Weak",
|
|
};
|
|
rows = rows.push(
|
|
column![
|
|
row![
|
|
text("Signal strength").size(12).width(Length::Fill),
|
|
text(strength).size(12)
|
|
],
|
|
level_meter(n.signal, primary, p),
|
|
]
|
|
.spacing(8),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if menu.kind == Menu::Bluetooth {
|
|
if let Some(b) = &self.status.bluetooth {
|
|
rows = rows.push(status_summary(
|
|
status_icon(Menu::Bluetooth, &self.status).0,
|
|
if b.enabled {
|
|
"Bluetooth is on"
|
|
} else {
|
|
"Bluetooth is off"
|
|
},
|
|
if !b.enabled {
|
|
"Device connections are paused"
|
|
} else if b.devices.is_empty() {
|
|
"No devices connected"
|
|
} else {
|
|
"Your connected devices"
|
|
},
|
|
primary,
|
|
muted,
|
|
color_with_opacity(theme.accent, p),
|
|
p,
|
|
Some((b.enabled, Action::Bluetooth(!b.enabled))),
|
|
));
|
|
if b.enabled && !b.devices.is_empty() {
|
|
let mut devices = column::with_capacity(b.devices.len()).spacing(8);
|
|
for device in &b.devices {
|
|
devices = devices.push(
|
|
column![
|
|
text(device).size(14),
|
|
text("Connected").size(12).class(theme::Text::Color(muted)),
|
|
]
|
|
.spacing(3),
|
|
);
|
|
}
|
|
let list: Element<'_, cosmic::Action<Message>> =
|
|
if let Some(height) = device_list_height(b.devices.len()) {
|
|
cosmic::iced::widget::scrollable(devices)
|
|
.height(height)
|
|
.width(Length::Fill)
|
|
.into()
|
|
} else {
|
|
devices.into()
|
|
};
|
|
rows = rows.push(list);
|
|
}
|
|
}
|
|
}
|
|
if combined || menu.kind == Menu::Audio {
|
|
if let Some(a) = &self.status.audio {
|
|
let audio = column![
|
|
row![
|
|
text("Volume").size(13).width(Length::Fill),
|
|
menu_button(
|
|
if a.muted { "Unmute" } else { "Mute" },
|
|
Message::Control(Action::Mute(!a.muted)),
|
|
primary,
|
|
p
|
|
)
|
|
]
|
|
.align_y(Alignment::Center),
|
|
text(&a.output).size(12).class(theme::Text::Color(muted)),
|
|
slider_row(
|
|
audio_icon(a.volume, a.muted),
|
|
a.volume,
|
|
false,
|
|
primary,
|
|
color_with_opacity(theme.accent, p),
|
|
p,
|
|
)
|
|
]
|
|
.spacing(4);
|
|
let audio: Element<'_, cosmic::Action<Message>> = if combined {
|
|
control_card(audio.into(), primary, p)
|
|
} else {
|
|
audio.into()
|
|
};
|
|
rows = rows.push(audio);
|
|
}
|
|
}
|
|
if combined {
|
|
if let Some(value) = self.status.brightness {
|
|
let brightness = column![
|
|
text("Brightness").size(13),
|
|
slider_row(
|
|
include_bytes!("../assets/icons/status/brightness.svg"),
|
|
value,
|
|
true,
|
|
primary,
|
|
color_with_opacity(theme.accent, p),
|
|
p,
|
|
)
|
|
]
|
|
.spacing(6);
|
|
rows = rows.push(control_card(brightness.into(), primary, p));
|
|
}
|
|
}
|
|
if combined || menu.kind == Menu::Notifications {
|
|
if let Some(n) = &self.status.notifications {
|
|
let toggle =
|
|
toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p);
|
|
rows = rows.push(if combined {
|
|
control_card(toggle, primary, p)
|
|
} else {
|
|
toggle
|
|
});
|
|
if menu.kind == Menu::Notifications {
|
|
rows = rows
|
|
.push(
|
|
text(if n.count == 0 {
|
|
"No notifications".to_owned()
|
|
} else if n.count == 1 {
|
|
"1 notification".to_owned()
|
|
} else {
|
|
format!("{} notifications", n.count)
|
|
})
|
|
.size(13)
|
|
.class(theme::Text::Color(muted)),
|
|
)
|
|
.push(menu_button(
|
|
"Open notification center",
|
|
Message::Control(Action::Notifications),
|
|
primary,
|
|
p,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if menu.kind == Menu::Battery {
|
|
if let Some(b) = &self.status.battery {
|
|
let battery_color = if b.percent < self.config.status.low_battery_threshold
|
|
&& b.status != "Charging"
|
|
{
|
|
Color {
|
|
a: p,
|
|
..Color::from_rgb8(230, 172, 90)
|
|
}
|
|
} else {
|
|
primary
|
|
};
|
|
rows = rows.push(
|
|
container(
|
|
row![
|
|
text(format!("{}%", b.percent)).size(24).width(Length::Fill),
|
|
accented_icon(
|
|
status_icon(Menu::Battery, &self.status).0,
|
|
24,
|
|
battery_color,
|
|
color_with_opacity(theme.accent, p),
|
|
),
|
|
]
|
|
.align_y(Alignment::Center),
|
|
)
|
|
.padding([2, 0])
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
text_color: Some(battery_color),
|
|
icon_color: Some(battery_color),
|
|
..Default::default()
|
|
})),
|
|
);
|
|
rows = rows.push(level_meter(b.percent, battery_color, p));
|
|
rows = rows.push(
|
|
text(match b.status.as_str() {
|
|
"Charging" => "Connected to power",
|
|
"Discharging" => "Running on battery power",
|
|
"Full" => "Battery fully charged",
|
|
_ => "Battery status reported by the system",
|
|
})
|
|
.size(12)
|
|
.class(theme::Text::Color(muted)),
|
|
);
|
|
}
|
|
}
|
|
if combined {
|
|
let mut actions = row::with_capacity(4).spacing(4);
|
|
if self
|
|
.config
|
|
.status
|
|
.settings_command
|
|
.as_ref()
|
|
.and_then(|a| a.first())
|
|
.is_some_and(|p| status::available(p))
|
|
{
|
|
actions = actions.push(menu_button(
|
|
"Settings",
|
|
Message::Control(Action::Settings),
|
|
primary,
|
|
p,
|
|
));
|
|
}
|
|
if self.status.poweroff {
|
|
actions = actions.push(menu_button(
|
|
"Power off…",
|
|
Message::ConfirmPower(Action::Poweroff),
|
|
primary,
|
|
p,
|
|
));
|
|
}
|
|
if self.status.reboot {
|
|
actions = actions.push(menu_button(
|
|
"Restart…",
|
|
Message::ConfirmPower(Action::Reboot),
|
|
primary,
|
|
p,
|
|
));
|
|
}
|
|
if self.status.suspend {
|
|
actions = actions.push(menu_button(
|
|
"Suspend…",
|
|
Message::ConfirmPower(Action::Suspend),
|
|
primary,
|
|
p,
|
|
));
|
|
}
|
|
rows = rows.push(control_card(actions.wrap().into(), primary, p));
|
|
}
|
|
if !menu.kind.available(&self.status) {
|
|
rows = rows.push(
|
|
text("Service unavailable")
|
|
.size(13)
|
|
.class(theme::Text::Color(muted)),
|
|
);
|
|
}
|
|
}
|
|
if let Some(error) = &self.status_error {
|
|
rows = rows.push(text(error).size(12).class(theme::Text::Color(Color {
|
|
a: p,
|
|
..Color::from_rgb8(230, 172, 90)
|
|
})));
|
|
}
|
|
let content: Element<'_, cosmic::Action<Message>> = if kind == Menu::System {
|
|
rows.into()
|
|
} else {
|
|
control_card(rows.into(), primary, p)
|
|
};
|
|
let panel =
|
|
container(content)
|
|
.width(kind.width())
|
|
.padding(12)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background: None,
|
|
text_color: Some(primary),
|
|
icon_color: Some(primary),
|
|
border: Border {
|
|
radius: theme.material_radius.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}));
|
|
// Keep the outer surface transparent; only inner cards paint a background.
|
|
cosmic::widget::autosize::autosize(
|
|
super::motion::animated(panel.into(), p, menu.regions.clone()),
|
|
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
|
|
)
|
|
.limits(
|
|
Limits::NONE
|
|
.min_width(kind.width())
|
|
.max_width(kind.width())
|
|
.max_height(720.0),
|
|
)
|
|
.into()
|
|
}
|
|
}
|
|
|
|
fn status_summary<'a>(
|
|
source: &'static [u8],
|
|
title: &'a str,
|
|
subtitle: &'a str,
|
|
primary: Color,
|
|
muted: Color,
|
|
accent: Color,
|
|
opacity: f32,
|
|
toggle: Option<(bool, Action)>,
|
|
) -> Element<'a, cosmic::Action<Message>> {
|
|
let enabled = toggle.as_ref().is_some_and(|(enabled, _)| *enabled);
|
|
let badge_color = if enabled { accent } else { primary };
|
|
let badge = container(accented_icon(source, 22, primary, accent))
|
|
.width(32)
|
|
.height(32)
|
|
.align_x(alignment::Horizontal::Center)
|
|
.align_y(alignment::Vertical::Center)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background: Some(Background::Color(Color {
|
|
a: if enabled {
|
|
0.22 * opacity
|
|
} else {
|
|
0.06 * opacity
|
|
},
|
|
..badge_color
|
|
})),
|
|
border: Border {
|
|
radius: 9.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}));
|
|
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
|
|
button::custom(badge)
|
|
.padding(0)
|
|
.name(if enabled { "Turn off" } else { "Turn on" })
|
|
.class(button_style(badge_color, false, opacity))
|
|
.on_press(cosmic::Action::App(Message::Control(action)))
|
|
.into()
|
|
} else {
|
|
badge.into()
|
|
};
|
|
let summary = row![
|
|
badge,
|
|
column![
|
|
text(title).size(14),
|
|
text(subtitle).size(12).class(theme::Text::Color(muted)),
|
|
]
|
|
.spacing(4)
|
|
.width(Length::Fill)
|
|
]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center);
|
|
summary.into()
|
|
}
|
|
|
|
fn level_meter(
|
|
value: u8,
|
|
foreground: Color,
|
|
opacity: f32,
|
|
) -> Element<'static, cosmic::Action<Message>> {
|
|
let segment = move |amount: u16, filled: bool| {
|
|
container(text(""))
|
|
.width(Length::FillPortion(amount))
|
|
.height(5)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background: Some(Background::Color(if filled {
|
|
foreground
|
|
} else {
|
|
Color {
|
|
a: 0.1 * opacity,
|
|
..foreground
|
|
}
|
|
})),
|
|
border: Border {
|
|
radius: 2.5.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}))
|
|
};
|
|
let value = u16::from(value.min(100));
|
|
let mut meter = row::with_capacity(2).width(Length::Fill);
|
|
if value > 0 {
|
|
meter = meter.push(segment(value, true));
|
|
}
|
|
if value < 100 {
|
|
meter = meter.push(segment(100 - value, false));
|
|
}
|
|
meter.into()
|
|
}
|
|
|
|
fn control_card<'a>(
|
|
content: Element<'a, cosmic::Action<Message>>,
|
|
foreground: Color,
|
|
opacity: f32,
|
|
) -> Element<'a, cosmic::Action<Message>> {
|
|
container(content)
|
|
.id("ferese-blur-card")
|
|
.padding(10)
|
|
.width(Length::Fill)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background: Some(Background::Color(Color {
|
|
a: 0.045 * opacity,
|
|
..foreground
|
|
})),
|
|
border: Border {
|
|
radius: 11.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}))
|
|
.into()
|
|
}
|
|
|
|
fn connection_control<'a>(
|
|
label: &'static str,
|
|
detail: &str,
|
|
source: &'static [u8],
|
|
enabled: bool,
|
|
action: Option<Action>,
|
|
theme: ShellTheme,
|
|
opacity: f32,
|
|
) -> Element<'a, cosmic::Action<Message>> {
|
|
let foreground = color_with_opacity(theme.text_primary, opacity);
|
|
let icon_accent = color_with_opacity(theme.accent, opacity);
|
|
let accent = color_with_opacity(theme.accent, opacity * 0.14);
|
|
let icon = container(accented_icon(source, 18, foreground, icon_accent))
|
|
.width(32)
|
|
.height(32)
|
|
.align_x(alignment::Horizontal::Center)
|
|
.align_y(alignment::Vertical::Center)
|
|
.class(theme::Container::custom(move |_| container::Style {
|
|
background: enabled.then_some(Background::Color(accent)),
|
|
icon_color: Some(color_with_opacity(
|
|
if enabled {
|
|
theme.text_primary
|
|
} else {
|
|
theme.text_muted
|
|
},
|
|
opacity,
|
|
)),
|
|
border: Border {
|
|
radius: 10.0.into(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}));
|
|
let content = column![
|
|
icon,
|
|
text(label).size(13),
|
|
text(detail.to_owned())
|
|
.size(12)
|
|
.width(Length::Fill)
|
|
.height(18)
|
|
.wrapping(cosmic::iced::widget::text::Wrapping::None)
|
|
.class(theme::Text::Color(color_with_opacity(
|
|
theme.text_muted,
|
|
opacity
|
|
)))
|
|
]
|
|
.spacing(6)
|
|
.width(Length::Fill);
|
|
// Battery is informational, not a toggle. Match its tile's layout without
|
|
// giving it a misleading clickable/disabled-button appearance.
|
|
let Some(action) = action else {
|
|
return container(content)
|
|
.id("ferese-blur-card")
|
|
.width(Length::FillPortion(1))
|
|
.padding(4)
|
|
.into();
|
|
};
|
|
container(
|
|
button::custom(content)
|
|
.width(Length::FillPortion(1))
|
|
.padding(4)
|
|
.class(button_style(foreground, false, opacity))
|
|
.on_press(cosmic::Action::App(Message::Control(action))),
|
|
)
|
|
.id("ferese-blur-card")
|
|
.width(Length::FillPortion(1))
|
|
.into()
|
|
}
|
|
|
|
fn menu_button<'a>(
|
|
label: &'a str,
|
|
message: Message,
|
|
foreground: Color,
|
|
opacity: f32,
|
|
) -> Element<'a, cosmic::Action<Message>> {
|
|
button::custom(text(label).size(13))
|
|
.padding([6, 8])
|
|
.class(button_style(foreground, false, opacity))
|
|
.on_press(cosmic::Action::App(message))
|
|
.into()
|
|
}
|
|
fn toggle_row<'a>(
|
|
label: &'a str,
|
|
on: bool,
|
|
action: Action,
|
|
foreground: Color,
|
|
opacity: f32,
|
|
) -> Element<'a, cosmic::Action<Message>> {
|
|
row![
|
|
text(label).width(Length::Fill).size(14),
|
|
menu_button(
|
|
if on { "On" } else { "Off" },
|
|
Message::Control(action),
|
|
foreground,
|
|
opacity
|
|
)
|
|
]
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
pub(super) fn button_style(foreground: Color, selected: bool, opacity: f32) -> theme::Button {
|
|
let style = move |strength: f32| button::Style {
|
|
text_color: Some(foreground),
|
|
icon_color: Some(foreground),
|
|
border_radius: 6.0.into(),
|
|
background: Some(Background::Color(Color {
|
|
a: strength * opacity,
|
|
..foreground
|
|
})),
|
|
..Default::default()
|
|
};
|
|
theme::Button::Custom {
|
|
active: Box::new(move |_, _| style(if selected { 0.14 } else { 0.0 })),
|
|
hovered: Box::new(move |_, _| style(if selected { 0.16 } else { 0.08 })),
|
|
pressed: Box::new(move |_, _| style(0.20)),
|
|
disabled: Box::new(move |_| style(0.0)),
|
|
}
|
|
}
|
|
|
|
fn slider_row(
|
|
source: &'static [u8],
|
|
value: u8,
|
|
brightness: bool,
|
|
foreground: Color,
|
|
icon_accent: Color,
|
|
opacity: f32,
|
|
) -> Element<'static, cosmic::Action<Message>> {
|
|
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
|
|
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
|
|
Style {
|
|
rail: Rail {
|
|
backgrounds: (
|
|
foreground.into(),
|
|
Color {
|
|
a: 0.16 * opacity,
|
|
..foreground
|
|
}
|
|
.into(),
|
|
),
|
|
width: 4.0,
|
|
border: Border {
|
|
radius: 2.0.into(),
|
|
..Default::default()
|
|
},
|
|
},
|
|
handle: Handle {
|
|
shape: HandleShape::Circle { radius: 5.0 },
|
|
background: foreground.into(),
|
|
border_width: 0.0,
|
|
border_color: Color::TRANSPARENT,
|
|
},
|
|
breakpoint: Breakpoint {
|
|
color: Color::TRANSPARENT,
|
|
},
|
|
}
|
|
});
|
|
let control = slider(
|
|
if brightness { 1..=100 } else { 0..=100 },
|
|
value,
|
|
move |value| {
|
|
cosmic::Action::App(Message::Control(if brightness {
|
|
Action::Brightness(value)
|
|
} else {
|
|
Action::Volume(value)
|
|
}))
|
|
},
|
|
)
|
|
.width(Length::Fill)
|
|
.height(24)
|
|
.class(theme::iced::Slider::Custom {
|
|
active: style.clone(),
|
|
hovered: style.clone(),
|
|
dragging: style,
|
|
});
|
|
row![
|
|
accented_icon(source, 18, foreground, icon_accent),
|
|
control,
|
|
text(format!("{value}%")).size(12).width(36)
|
|
]
|
|
.spacing(8)
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
}
|
|
|
|
fn status_label(kind: Menu, s: &Snapshot) -> String {
|
|
match kind {
|
|
Menu::Network => match &s.network {
|
|
Some(n) if n.enabled => format!(
|
|
"Wi-Fi: {}",
|
|
n.connection.as_deref().unwrap_or("not connected")
|
|
),
|
|
_ => "Wi-Fi: off".into(),
|
|
},
|
|
Menu::Bluetooth => match &s.bluetooth {
|
|
Some(b) if b.enabled => format!("Bluetooth: on, {} devices connected", b.devices.len()),
|
|
_ => "Bluetooth: off".into(),
|
|
},
|
|
Menu::Audio => match &s.audio {
|
|
Some(a) if !a.muted && a.volume > 0 => format!("Audio: {}%", a.volume),
|
|
_ => "Audio: muted".into(),
|
|
},
|
|
Menu::Notifications => match &s.notifications {
|
|
Some(n) if n.dnd => "Notifications: do not disturb".into(),
|
|
Some(n) => format!("Notifications: {} unread", n.count),
|
|
_ => "Notifications: unavailable".into(),
|
|
},
|
|
Menu::Battery => s.battery.as_ref().map_or_else(
|
|
|| "Battery: unavailable".into(),
|
|
|b| format!("Battery: {}%, {}", b.percent, b.status),
|
|
),
|
|
Menu::System => "Control Center".into(),
|
|
}
|
|
}
|
|
|
|
fn audio_icon(volume: u8, muted: bool) -> &'static [u8] {
|
|
if muted || volume == 0 {
|
|
include_bytes!("../assets/icons/status/volume-mute.svg")
|
|
} else if volume <= 33 {
|
|
include_bytes!("../assets/icons/status/volume-low.svg")
|
|
} else if volume <= 66 {
|
|
include_bytes!("../assets/icons/status/volume-medium.svg")
|
|
} else {
|
|
include_bytes!("../assets/icons/status/volume-high.svg")
|
|
}
|
|
}
|
|
fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
|
match kind {
|
|
Menu::Network => {
|
|
let Some(n) = &s.network else {
|
|
return (include_bytes!("../assets/icons/status/wifi-off.svg"), false);
|
|
};
|
|
if !n.enabled || n.connection.is_none() {
|
|
(include_bytes!("../assets/icons/status/wifi-off.svg"), false)
|
|
} else if n.signal <= 33 {
|
|
(include_bytes!("../assets/icons/status/wifi-low.svg"), true)
|
|
} else if n.signal <= 66 {
|
|
(
|
|
include_bytes!("../assets/icons/status/wifi-medium.svg"),
|
|
true,
|
|
)
|
|
} else {
|
|
(include_bytes!("../assets/icons/status/wifi-full.svg"), true)
|
|
}
|
|
}
|
|
Menu::Bluetooth => match &s.bluetooth {
|
|
Some(b) if b.enabled && !b.devices.is_empty() => (
|
|
include_bytes!("../assets/icons/status/bluetooth-connected.svg"),
|
|
true,
|
|
),
|
|
Some(b) if b.enabled => (
|
|
include_bytes!("../assets/icons/status/bluetooth-on.svg"),
|
|
true,
|
|
),
|
|
_ => (
|
|
include_bytes!("../assets/icons/status/bluetooth-off.svg"),
|
|
false,
|
|
),
|
|
},
|
|
Menu::Audio => s.audio.as_ref().map_or((audio_icon(0, true), false), |a| {
|
|
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
|
|
}),
|
|
Menu::Battery => {
|
|
let Some(b) = &s.battery else {
|
|
return (
|
|
include_bytes!("../assets/icons/status/battery-empty.svg"),
|
|
false,
|
|
);
|
|
};
|
|
let icon: &'static [u8] = if b.status == "Charging" {
|
|
include_bytes!("../assets/icons/status/battery-charging.svg")
|
|
} else if b.percent >= 80 {
|
|
include_bytes!("../assets/icons/status/battery-full.svg")
|
|
} else if b.percent >= 50 {
|
|
include_bytes!("../assets/icons/status/battery-75.svg")
|
|
} else if b.percent >= 20 {
|
|
include_bytes!("../assets/icons/status/battery-50.svg")
|
|
} else if b.percent > 0 {
|
|
include_bytes!("../assets/icons/status/battery-25.svg")
|
|
} else {
|
|
include_bytes!("../assets/icons/status/battery-empty.svg")
|
|
};
|
|
(icon, true)
|
|
}
|
|
Menu::Notifications => {
|
|
if s.notifications.as_ref().is_some_and(|n| n.dnd) {
|
|
(
|
|
include_bytes!("../assets/icons/status/notifications-off.svg"),
|
|
false,
|
|
)
|
|
} else {
|
|
(
|
|
include_bytes!("../assets/icons/status/notifications.svg"),
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
Menu::System => (
|
|
include_bytes!("../assets/icons/status/control-center.svg"),
|
|
true,
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn smaller_popups_have_content_specific_widths() {
|
|
assert_eq!(Menu::System.width(), 320.0);
|
|
assert_eq!(Menu::Network.width(), 280.0);
|
|
assert_eq!(Menu::Bluetooth.width(), 280.0);
|
|
assert_eq!(Menu::Battery.width(), 250.0);
|
|
assert_eq!(Menu::Audio.width(), 290.0);
|
|
assert_eq!(Menu::Notifications.width(), 290.0);
|
|
}
|
|
|
|
#[test]
|
|
fn short_device_lists_shrink_and_long_lists_are_bounded() {
|
|
for count in 0..=4 {
|
|
assert_eq!(device_list_height(count), None);
|
|
}
|
|
for count in [5, 20, 1000] {
|
|
assert_eq!(device_list_height(count), Some(180.0));
|
|
}
|
|
}
|
|
use super::*;
|
|
#[test]
|
|
fn completed_open_stops_requesting_animation_ticks() {
|
|
let past = Instant::now() - Duration::from_secs(1);
|
|
let menu = OpenMenu {
|
|
id: window::Id::unique(),
|
|
kind: Menu::Network,
|
|
opened: past,
|
|
confirm: None,
|
|
effects: None,
|
|
regions: Default::default(),
|
|
};
|
|
assert_eq!(menu.progress(), 1.0);
|
|
assert!(!menu.animating());
|
|
}
|
|
|
|
#[test]
|
|
fn services_are_hidden_until_available() {
|
|
let s = Snapshot::default();
|
|
assert!(!Menu::Network.available(&s));
|
|
assert!(!Menu::Notifications.available(&s));
|
|
assert!(Menu::System.available(&s));
|
|
}
|
|
#[test]
|
|
fn icons_follow_real_states() {
|
|
let mut s = Snapshot::default();
|
|
s.audio = Some(status::Audio {
|
|
volume: 80,
|
|
muted: true,
|
|
output: String::new(),
|
|
});
|
|
assert_eq!(
|
|
status_icon(Menu::Audio, &s),
|
|
(
|
|
include_bytes!("../assets/icons/status/volume-mute.svg").as_slice(),
|
|
false
|
|
)
|
|
);
|
|
s.battery = Some(status::Battery {
|
|
percent: 2,
|
|
status: "Charging".into(),
|
|
});
|
|
assert_eq!(
|
|
status_icon(Menu::Battery, &s).0,
|
|
include_bytes!("../assets/icons/status/battery-charging.svg")
|
|
);
|
|
s.notifications = Some(status::Notifications {
|
|
count: 3,
|
|
dnd: true,
|
|
});
|
|
assert_eq!(
|
|
status_icon(Menu::Notifications, &s).0,
|
|
include_bytes!("../assets/icons/status/notifications-off.svg")
|
|
);
|
|
assert_eq!(
|
|
status_label(Menu::Notifications, &s),
|
|
"Notifications: do not disturb"
|
|
);
|
|
s.bluetooth = Some(status::Bluetooth {
|
|
enabled: false,
|
|
devices: vec!["Headphones".into()],
|
|
});
|
|
assert_eq!(
|
|
status_icon(Menu::Bluetooth, &s).0,
|
|
include_bytes!("../assets/icons/status/bluetooth-off.svg")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn battery_bands_and_live_label_follow_charge_state() {
|
|
let mut s = Snapshot::default();
|
|
for (percent, expected) in [
|
|
(
|
|
0,
|
|
include_bytes!("../assets/icons/status/battery-empty.svg").as_slice(),
|
|
),
|
|
(
|
|
19,
|
|
include_bytes!("../assets/icons/status/battery-25.svg").as_slice(),
|
|
),
|
|
(
|
|
20,
|
|
include_bytes!("../assets/icons/status/battery-50.svg").as_slice(),
|
|
),
|
|
(
|
|
50,
|
|
include_bytes!("../assets/icons/status/battery-75.svg").as_slice(),
|
|
),
|
|
(
|
|
80,
|
|
include_bytes!("../assets/icons/status/battery-full.svg").as_slice(),
|
|
),
|
|
] {
|
|
s.battery = Some(status::Battery {
|
|
percent,
|
|
status: "Discharging".into(),
|
|
});
|
|
assert_eq!(status_icon(Menu::Battery, &s).0, expected);
|
|
assert_eq!(
|
|
status_label(Menu::Battery, &s),
|
|
format!("Battery: {percent}%, Discharging")
|
|
);
|
|
}
|
|
}
|
|
}
|