Refine status popovers and toggle radios through status icons

This commit is contained in:
Samuel Onoja committed 2026-09-26 09:21:09 +01:00
1 parent 64c95f0b97
commit a0fd760737
1 file changed
+130 -80
+130 -80
View File
@@ -4,9 +4,13 @@ use cosmic::iced::{Alignment, Rectangle};
use cosmic::widget::{column, slider}; use cosmic::widget::{column, slider};
use status::{Action, Snapshot}; use status::{Action, Snapshot};
const WIDTH: f32 = 320.0; const DEVICE_LIST_HEIGHT: f32 = 180.0;
const OPEN_MS: f32 = 160.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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu { pub enum Menu {
Network, Network,
@@ -17,6 +21,14 @@ pub enum Menu {
System, System,
} }
impl Menu { 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> { pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
// Popovers contain independent cards, with no shared material backing. // Popovers contain independent cards, with no shared material backing.
None None
@@ -93,8 +105,8 @@ impl FereseShell {
gravity: 6u32.try_into().unwrap(), // bottom-left gravity: 6u32.try_into().unwrap(), // bottom-left
offset: (anchor.width / 2, 4), offset: (anchor.width / 2, 4),
size_limits: Limits::NONE size_limits: Limits::NONE
.min_width(WIDTH) .min_width(kind.width())
.max_width(WIDTH) .max_width(kind.width())
.max_height(720.0), .max_height(720.0),
constraint_adjustment: 3, // slide X/Y, never flip above the bar constraint_adjustment: 3, // slide X/Y, never flip above the bar
..Default::default() ..Default::default()
@@ -232,30 +244,10 @@ impl FereseShell {
}; };
let theme = self.config.theme; let theme = self.config.theme;
let p = menu.progress(); let p = menu.progress();
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p); let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p); let muted = color_with_opacity(theme.text_muted, p);
let mut rows = column::with_capacity(12).spacing(8).width(Length::Fill); let mut rows = column::with_capacity(12).spacing(8).width(Length::Fill);
let radio = match menu.kind {
Menu::Network => self
.status
.network
.as_ref()
.map(|n| (n.enabled, Action::Wifi(!n.enabled))),
Menu::Bluetooth => self
.status
.bluetooth
.as_ref()
.map(|b| (b.enabled, Action::Bluetooth(!b.enabled))),
_ => None,
};
if let Some((enabled, action)) = radio {
rows = rows.push(menu_button(
if enabled { "Turn off" } else { "Turn on" },
Message::Control(action),
primary,
p,
));
}
if let Some(action) = &menu.confirm { if let Some(action) = &menu.confirm {
let title = match action { let title = match action {
Action::Poweroff => "Power off this computer?", Action::Poweroff => "Power off this computer?",
@@ -352,6 +344,7 @@ impl FereseShell {
muted, muted,
color_with_opacity(theme.accent, p), color_with_opacity(theme.accent, p),
p, p,
Some((n.enabled, Action::Wifi(!n.enabled))),
)); ));
if connected { if connected {
let strength = match n.signal { let strength = match n.signal {
@@ -360,7 +353,7 @@ impl FereseShell {
25..=49 => "Fair", 25..=49 => "Fair",
_ => "Weak", _ => "Weak",
}; };
rows = rows.push(control_card( rows = rows.push(
column![ column![
row![ row![
text("Signal strength").size(12).width(Length::Fill), text("Signal strength").size(12).width(Length::Fill),
@@ -368,11 +361,8 @@ impl FereseShell {
], ],
level_meter(n.signal, primary, p), level_meter(n.signal, primary, p),
] ]
.spacing(8) .spacing(8),
.into(), );
primary,
p,
));
} }
} }
} }
@@ -396,9 +386,10 @@ impl FereseShell {
muted, muted,
color_with_opacity(theme.accent, p), color_with_opacity(theme.accent, p),
p, p,
Some((b.enabled, Action::Bluetooth(!b.enabled))),
)); ));
if b.enabled && !b.devices.is_empty() { if b.enabled && !b.devices.is_empty() {
let mut devices = column::with_capacity(b.devices.len()).spacing(12); let mut devices = column::with_capacity(b.devices.len()).spacing(8);
for device in &b.devices { for device in &b.devices {
devices = devices.push( devices = devices.push(
column![ column![
@@ -408,7 +399,16 @@ impl FereseShell {
.spacing(3), .spacing(3),
); );
} }
rows = rows.push(control_card(devices.into(), primary, p)); 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);
} }
} }
} }
@@ -436,7 +436,12 @@ impl FereseShell {
) )
] ]
.spacing(4); .spacing(4);
rows = rows.push(control_card(audio.into(), primary, p)); let audio: Element<'_, cosmic::Action<Message>> = if combined {
control_card(audio.into(), primary, p)
} else {
audio.into()
};
rows = rows.push(audio);
} }
} }
if combined { if combined {
@@ -458,17 +463,25 @@ impl FereseShell {
} }
if combined || menu.kind == Menu::Notifications { if combined || menu.kind == Menu::Notifications {
if let Some(n) = &self.status.notifications { if let Some(n) = &self.status.notifications {
rows = rows.push(control_card( let toggle =
toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p), toggle_row("Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), primary, p);
primary, rows = rows.push(if combined {
p, control_card(toggle, primary, p)
)); } else {
toggle
});
if menu.kind == Menu::Notifications { if menu.kind == Menu::Notifications {
rows = rows rows = rows
.push( .push(
text(format!("{} notifications", n.count)) text(if n.count == 0 {
.size(13) "No notifications".to_owned()
.class(theme::Text::Color(muted)), } else if n.count == 1 {
"1 notification".to_owned()
} else {
format!("{} notifications", n.count)
})
.size(13)
.class(theme::Text::Color(muted)),
) )
.push(menu_button( .push(menu_button(
"Open notification center", "Open notification center",
@@ -494,17 +507,17 @@ impl FereseShell {
rows = rows.push( rows = rows.push(
container( container(
row![ row![
text(format!("{}%", b.percent)).size(40).width(Length::Fill), text(format!("{}%", b.percent)).size(24).width(Length::Fill),
accented_icon( accented_icon(
status_icon(Menu::Battery, &self.status).0, status_icon(Menu::Battery, &self.status).0,
40, 24,
battery_color, battery_color,
color_with_opacity(theme.accent, p), color_with_opacity(theme.accent, p),
), ),
] ]
.align_y(Alignment::Center), .align_y(Alignment::Center),
) )
.padding([10, 0]) .padding([2, 0])
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
text_color: Some(battery_color), text_color: Some(battery_color),
icon_color: Some(battery_color), icon_color: Some(battery_color),
@@ -512,7 +525,6 @@ impl FereseShell {
})), })),
); );
rows = rows.push(level_meter(b.percent, battery_color, p)); rows = rows.push(level_meter(b.percent, battery_color, p));
rows = rows.push(text(&b.status).size(14));
rows = rows.push( rows = rows.push(
text(match b.status.as_str() { text(match b.status.as_str() {
"Charging" => "Connected to power", "Charging" => "Connected to power",
@@ -582,19 +594,25 @@ impl FereseShell {
..Color::from_rgb8(230, 172, 90) ..Color::from_rgb8(230, 172, 90)
}))); })));
} }
let panel = container(rows) let content: Element<'_, cosmic::Action<Message>> = if kind == Menu::System {
.width(WIDTH) rows.into()
.padding(12) } else {
.class(theme::Container::custom(move |_| container::Style { control_card(rows.into(), primary, p)
background: None, };
text_color: Some(primary), let panel =
icon_color: Some(primary), container(content)
border: Border { .width(kind.width())
radius: theme.material_radius.into(), .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() ..Default::default()
}, }));
..Default::default()
}));
// Keep the outer surface transparent; only inner cards paint a background. // Keep the outer surface transparent; only inner cards paint a background.
cosmic::widget::autosize::autosize( cosmic::widget::autosize::autosize(
super::motion::animated(panel.into(), p, menu.regions.clone()), super::motion::animated(panel.into(), p, menu.regions.clone()),
@@ -602,8 +620,8 @@ impl FereseShell {
) )
.limits( .limits(
Limits::NONE Limits::NONE
.min_width(WIDTH) .min_width(kind.width())
.max_width(WIDTH) .max_width(kind.width())
.max_height(720.0), .max_height(720.0),
) )
.into() .into()
@@ -618,39 +636,52 @@ fn status_summary<'a>(
muted: Color, muted: Color,
accent: Color, accent: Color,
opacity: f32, opacity: f32,
toggle: Option<(bool, Action)>,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
let badge = container(accented_icon(source, 24, primary, accent)) let enabled = toggle.as_ref().is_some_and(|(enabled, _)| *enabled);
.width(48) let badge_color = if enabled { accent } else { primary };
.height(48) let badge = container(accented_icon(source, 22, primary, accent))
.width(32)
.height(32)
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color { background: Some(Background::Color(Color {
a: 0.06 * opacity, a: if enabled {
..primary 0.22 * opacity
} else {
0.06 * opacity
},
..badge_color
})), })),
border: Border { border: Border {
radius: 14.0.into(), radius: 9.0.into(),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
})); }));
container( let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
row![ button::custom(badge)
badge, .padding(0)
column![ .name(if enabled { "Turn off" } else { "Turn on" })
text(title).size(18), .class(button_style(badge_color, false, opacity))
text(subtitle).size(12).class(theme::Text::Color(muted)), .on_press(cosmic::Action::App(Message::Control(action)))
] .into()
.spacing(4) } else {
.width(Length::Fill) badge.into()
};
let summary = row![
badge,
column![
text(title).size(14),
text(subtitle).size(12).class(theme::Text::Color(muted)),
] ]
.spacing(12) .spacing(4)
.align_y(Alignment::Center), .width(Length::Fill)
) ]
.id("ferese-blur-card") .spacing(8)
.padding([12, 0]) .align_y(Alignment::Center);
.into() summary.into()
} }
fn level_meter( fn level_meter(
@@ -1017,6 +1048,25 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
fn completed_open_stops_requesting_animation_ticks() { fn completed_open_stops_requesting_animation_ticks() {