Confirm power and logout actions in centered system modals

This commit is contained in:
Samuel Onoja committed 2026-09-29 22:21:15 +01:00
1 parent 4ca3a59cb7
commit d8a23748c8
14 files changed
+673 -49

No files matched your search

+44 -2
View File
@@ -13,7 +13,7 @@ use ferese_protocols::shell::v1::client::{
ferese_shell_v1::{self, FereseShellV1},
};
use wayland_client::{
Connection, Dispatch, QueueHandle, WEnum, delegate_noop,
Connection, Dispatch, Proxy, QueueHandle, WEnum, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::wl_registry,
};
@@ -63,6 +63,8 @@ pub(crate) struct ControlPoll {
pub(crate) snapshot: Option<ShellSnapshot>,
pub(crate) overview_active: Option<bool>,
pub(crate) disconnected: bool,
pub(crate) logout: Option<(u32, String)>,
pub(crate) logout_cancelled: Vec<u32>,
}
enum ControlUpdate {
@@ -70,6 +72,8 @@ enum ControlUpdate {
Snapshot(ShellSnapshot),
OverviewState(bool),
Disconnected,
Logout(u32, String),
LogoutCancelled(u32),
}
impl ShellControl {
@@ -77,7 +81,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=2, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=3, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender);
@@ -104,10 +108,23 @@ impl ShellControl {
snapshot: None,
overview_active: None,
disconnected: false,
logout: None,
logout_cancelled: Vec::new(),
};
loop {
match self.updates.try_recv() {
Ok(ControlUpdate::Logout(serial, output)) => poll.logout = Some((serial, output)),
Ok(ControlUpdate::LogoutCancelled(serial)) => {
if poll
.logout
.as_ref()
.is_some_and(|(pending, _)| *pending == serial)
{
poll.logout = None;
}
poll.logout_cancelled.push(serial);
}
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
@@ -133,6 +150,20 @@ impl ShellControl {
let _ = self.connection.flush();
}
pub(crate) fn confirm_logout(&self, serial: u32) {
if self.shell.version() >= 3 {
self.shell.confirm_logout(serial);
let _ = self.connection.flush();
}
}
pub(crate) fn cancel_logout(&self, serial: u32) {
if self.shell.version() >= 3 {
self.shell.cancel_logout(serial);
let _ = self.connection.flush();
}
}
pub(crate) fn set_overview_active(&self, active: bool) {
if active {
self.shell.enter_overview();
@@ -282,6 +313,17 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
.sender
.send(ControlUpdate::Snapshot(state.pending.clone()));
}
ferese_shell_v1::Event::LogoutRequested {
serial,
output_name,
} => {
let _ = state
.sender
.send(ControlUpdate::Logout(serial, output_name));
}
ferese_shell_v1::Event::LogoutCancelled { serial } => {
let _ = state.sender.send(ControlUpdate::LogoutCancelled(serial));
}
ferese_shell_v1::Event::OverviewState { active } => {
let _ = state.sender.send(ControlUpdate::OverviewState(active != 0));
}
+74 -12
View File
@@ -7,6 +7,7 @@ mod notifications;
mod recording;
mod status;
mod status_ui;
mod system_modal;
use cosmic::Element;
use cosmic::app::{Core, Settings, Task};
@@ -174,6 +175,8 @@ struct FereseShell {
calendar_offset: i32,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
power_modal: Option<system_modal::PowerModal>,
pending_power: Option<(window::Id, system_modal::PowerAction)>,
note_editor: Option<DesktopNoteEditor>,
note_drag: Option<NoteDrag>,
note_pointer: std::collections::HashMap<window::Id, cosmic::iced::Point>,
@@ -249,6 +252,9 @@ enum Message {
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
ExecutePower,
AnimatePower,
PowerCompleted(window::Id, Result<(), String>),
CalendarMonth(i32),
CalendarToday,
}
@@ -286,6 +292,8 @@ impl cosmic::Application for FereseShell {
calendar_offset: 0,
status_error: None,
menu: None,
power_modal: None,
pending_power: None,
note_editor: None,
note_drag: None,
note_pointer: Default::default(),
@@ -322,6 +330,15 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
if self
.power_modal
.as_ref()
.is_some_and(|modal| modal.motion.animating() || modal.motion.closing())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimatePower)
} else {
Subscription::none()
},
self.notifications
.subscription()
.map(Message::NotificationEvent),
@@ -407,6 +424,8 @@ impl cosmic::Application for FereseShell {
}
}
self.update_power_materials();
match message {
Message::BeginNoteEdit(id) => {
let mut tasks = vec![self.finish_note_edit()];
@@ -490,6 +509,7 @@ impl cosmic::Application for FereseShell {
self.attach_effects(id, &surface)
}
Ok((_connection, surface)) => {
self.attach_power_material(id, &surface);
if let Some(entry) = &mut self.notification_surface
&& entry.id == id
&& entry.effects.is_none()
@@ -609,11 +629,14 @@ impl cosmic::Application for FereseShell {
}
}
Message::ConfirmPower(action) => {
if let Some(menu) = &mut self.menu {
menu.confirm = Some(action);
if let Some(action) = system_modal::PowerAction::from_status(action) {
return self.open_power_modal(action, None);
}
Task::none()
}
Message::ExecutePower => self.execute_power_modal(),
Message::AnimatePower => self.animate_power_modal(),
Message::PowerCompleted(id, result) => self.finish_power_action(id, result),
Message::CalendarMonth(delta) => {
self.calendar_offset = (self.calendar_offset + delta).clamp(-1200, 1200);
Task::none()
@@ -622,13 +645,12 @@ impl cosmic::Application for FereseShell {
self.calendar_offset = 0;
Task::none()
}
Message::CancelPower => {
if let Some(menu) = &mut self.menu {
menu.confirm = None;
}
Task::none()
}
Message::CancelPower => self.close_power_modal(),
Message::Control(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action.clone()) {
return self.open_power_modal(action, None);
}
if let status::Action::PowerProfile(profile) = action {
self.status_error = self
.status_service
@@ -738,7 +760,19 @@ impl cosmic::Application for FereseShell {
if self.bar_hidden(self.bar_surface_id) {
tasks.push(self.destroy_menu());
}
return Task::batch(tasks);
reload_task = Task::batch(tasks);
}
for serial in poll.logout_cancelled {
reload_task = Task::batch([reload_task, self.cancel_logout_modal(serial)]);
}
if let Some((serial, output)) = poll.logout {
reload_task = Task::batch([
reload_task,
self.open_power_modal(
system_modal::PowerAction::Logout(serial),
Some(&output),
),
]);
}
}
reload_task
@@ -904,7 +938,11 @@ impl FereseShell {
Task::none()
};
let mut tasks = vec![destroy_layer_surface(entry.bar), menu];
let mut tasks = vec![
destroy_layer_surface(entry.bar),
menu,
self.destroy_power_modal(true),
];
if self.note_drag.as_ref().is_some_and(|drag| {
entry.clock == Some(drag.source)
|| entry.notes.iter().any(|(_, id)| *id == drag.source)
@@ -1046,6 +1084,7 @@ impl FereseShell {
Task::batch(tasks),
self.rebuild_clocks(false),
self.rebuild_notes(false),
self.destroy_power_modal(true),
])
}
@@ -1836,6 +1875,9 @@ impl FereseShell {
..
})
) {
if self.power_modal.is_some() {
return self.close_power_modal();
}
if self.note_drag.is_some() {
return self.finish_note_drag(false);
}
@@ -1857,6 +1899,10 @@ impl FereseShell {
Event::Window(window::Event::Opened { .. })
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self
.power_modal
.as_ref()
.is_some_and(|modal| modal.contains(id))
|| self
.notification_surface
.as_ref()
@@ -1944,6 +1990,14 @@ impl FereseShell {
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
Task::none()
}
Event::Window(window::Event::Closed)
if self
.power_modal
.as_ref()
.is_some_and(|modal| modal.contains(id)) =>
{
self.close_power_modal()
}
Event::Window(window::Event::Closed) => Task::none(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_,
@@ -2002,6 +2056,7 @@ impl FereseShell {
layer_id,
))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => {
self.attach_effects(layer_id, &surface);
self.attach_power_material(layer_id, &surface);
Task::none()
}
_ => Task::none(),
@@ -2644,6 +2699,14 @@ impl EffectsBinding {
}
fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box<dyn std::error::Error>> {
self.set_material_regions(regions, ferese_surface_effects_v1::Role::Popover)
}
fn set_material_regions(
&self,
regions: &[[i32; 5]],
role: ferese_surface_effects_v1::Role,
) -> Result<(), Box<dyn std::error::Error>> {
if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) {
return Ok(());
}
@@ -2656,8 +2719,7 @@ impl EffectsBinding {
if regions.is_empty() {
self.surface.clear_role();
} else {
self.surface
.set_role(ferese_surface_effects_v1::Role::Popover);
self.surface.set_role(role);
}
self.connection.flush()?;
*self.regions.borrow_mut() = Some(regions.to_vec());
+15 -1
View File
@@ -254,13 +254,17 @@ pub fn available(program: &str) -> bool {
}
fn run(program: &str, args: &[&str]) -> Result<String, String> {
run_with_timeout(program, args, "2s")
}
fn run_with_timeout(program: &str, args: &[&str], timeout: &str) -> Result<String, String> {
if !available(program) {
return Err(format!("{program} is not installed"));
}
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
let output = Command::new("timeout")
.args(["--kill-after=1s", "2s", program])
.args(["--kill-after=1s", timeout, program])
.args(args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
@@ -519,6 +523,16 @@ fn notifications(bus: &mut StatusBus) -> Option<Notifications> {
Some(Notifications { count, dnd })
}
pub(super) fn execute_power(action: Action) -> Result<(), String> {
let action = match action {
Action::Poweroff => "poweroff",
Action::Reboot => "reboot",
Action::Suspend => "suspend",
_ => return Err("Invalid power action".to_owned()),
};
run_with_timeout("systemctl", &[action], "120s").map(|_| ())
}
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
match action {
Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]),
+1 -27
View File
@@ -71,7 +71,6 @@ pub struct OpenMenu {
pub id: window::Id,
pub kind: Menu,
pub motion: super::motion::PopupMotion,
pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
}
@@ -118,7 +117,6 @@ impl FereseShell {
id,
kind,
motion: super::motion::PopupMotion::new(self.config.animations),
confirm: None,
effects: None,
regions: Default::default(),
});
@@ -456,30 +454,7 @@ impl FereseShell {
if kind != Menu::Calendar {
rows = rows.push(heading);
}
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 if kind == Menu::Calendar {
if kind == Menu::Calendar {
rows = rows.push(calendar_grid(self.calendar_offset, theme, p));
} else {
let combined = menu.kind == Menu::System;
@@ -1397,7 +1372,6 @@ mod tests {
id: window::Id::unique(),
kind: Menu::Network,
motion,
confirm: None,
effects: None,
regions: Default::default(),
};
+416
View File
@@ -0,0 +1,416 @@
use super::*;
use cosmic::iced::widget::Space;
use cosmic::widget::{column, text};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PowerAction {
Poweroff,
Reboot,
Suspend,
Logout(u32),
}
impl PowerAction {
pub(super) fn from_status(action: status::Action) -> Option<Self> {
match action {
status::Action::Poweroff => Some(Self::Poweroff),
status::Action::Reboot => Some(Self::Reboot),
status::Action::Suspend => Some(Self::Suspend),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Poweroff => "Power off",
Self::Reboot => "Restart",
Self::Suspend => "Suspend",
Self::Logout(_) => "Log out",
}
}
fn title(self) -> &'static str {
match self {
Self::Poweroff => "Power off this computer?",
Self::Reboot => "Restart this computer?",
Self::Suspend => "Suspend this computer?",
Self::Logout(_) => "Log out of Ferese?",
}
}
fn description(self) -> &'static str {
match self {
Self::Suspend => "Your apps will stay open. The session will not be locked.",
Self::Logout(_) => "Save your work. Your apps will close when you log out.",
_ => "Save your work before continuing.",
}
}
}
struct ModalSurface {
id: window::Id,
primary: bool,
effects: Option<EffectsBinding>,
regions: motion::Regions,
}
pub(super) struct PowerModal {
action: PowerAction,
surfaces: Vec<ModalSurface>,
pub(super) motion: motion::PopupMotion,
error: Option<String>,
}
impl PowerModal {
pub(super) fn contains(&self, id: window::Id) -> bool {
self.surfaces.iter().any(|surface| surface.id == id)
}
}
impl FereseShell {
pub(super) fn open_power_modal(
&mut self,
action: PowerAction,
output_name: Option<&str>,
) -> Task<Message> {
if self.pending_power.is_some() || self.outputs.is_empty() {
if let PowerAction::Logout(serial) = action
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
}
return Task::none();
}
if self
.power_modal
.as_ref()
.is_some_and(|modal| modal.action == action && !modal.motion.closing())
{
return Task::none();
}
let primary = output_name
.and_then(|name| {
self.outputs
.iter()
.position(|output| output.name.as_deref() == Some(name))
})
.or_else(|| {
self.outputs
.iter()
.position(|output| output.bar == self.bar_surface_id)
})
.unwrap_or(0);
let mut tasks = vec![
self.destroy_power_modal(true),
self.destroy_menu(),
self.close_notification_history(),
];
let mut surfaces = Vec::new();
let mut outputs = (0..self.outputs.len()).collect::<Vec<_>>();
outputs.sort_by_key(|index| *index == primary);
for index in outputs {
let id = window::Id::unique();
let output = self.outputs[index].output.clone();
let primary = index == primary;
surfaces.push(ModalSurface {
id,
primary,
effects: None,
regions: Default::default(),
});
let action = cosmic::surface::action::app_layer_shell::<Self>(
|_| Default::default(),
move |_| SctkLayerSurfaceSettings {
id,
layer: Layer::Overlay,
keyboard_interactivity: if primary {
KeyboardInteractivity::Exclusive
} else {
KeyboardInteractivity::None
},
anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT,
output: IcedOutput::Output(output.clone()),
namespace: "ferese-system-modal".to_owned(),
exclusive_zone: -1,
size: Some((None, None)),
..Default::default()
},
Some(Box::new(move |app| app.view_power_modal(id))),
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
self.power_modal = Some(PowerModal {
action,
surfaces,
motion: motion::PopupMotion::new(self.config.animations),
error: None,
});
Task::batch(tasks)
}
pub(super) fn attach_power_material(
&mut self,
id: window::Id,
surface: &wl_surface::WlSurface,
) {
let Some(modal) = &mut self.power_modal else {
return;
};
let Some(entry) = modal.surfaces.iter_mut().find(|entry| entry.id == id) else {
return;
};
modal.motion.begin(Instant::now());
if entry.primary && entry.effects.is_none() {
match EffectsBinding::attach_role(surface, None, 0.0) {
Ok(effects) => entry.effects = Some(effects),
Err(error) => eprintln!("ferese-shell: system modal material unavailable: {error}"),
}
}
}
pub(super) fn update_power_materials(&self) {
if let Some(modal) = &self.power_modal {
for entry in &modal.surfaces {
if let Some(effects) = &entry.effects {
let regions = entry.regions.lock().unwrap().clone();
let _ = effects
.set_material_regions(&regions, ferese_surface_effects_v1::Role::Modal);
let _ = effects.set_opacity(modal.motion.progress());
}
}
}
}
pub(super) fn close_power_modal(&mut self) -> Task<Message> {
if let Some(modal) = &mut self.power_modal {
if let PowerAction::Logout(serial) = modal.action
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
}
modal.motion.retarget(0.0, Instant::now());
}
self.animate_power_modal()
}
pub(super) fn animate_power_modal(&mut self) -> Task<Message> {
if self
.power_modal
.as_ref()
.is_some_and(|modal| modal.motion.closing() && !modal.motion.animating())
{
return self.destroy_power_modal(false);
}
self.update_power_materials();
Task::none()
}
pub(super) fn destroy_power_modal(&mut self, cancel: bool) -> Task<Message> {
let Some(modal) = self.power_modal.take() else {
return Task::none();
};
if cancel
&& let PowerAction::Logout(serial) = modal.action
&& let Some(control) = &self.control
{
control.cancel_logout(serial);
}
Task::batch(
modal
.surfaces
.into_iter()
.map(|surface| destroy_layer_surface(surface.id)),
)
}
pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task<Message> {
if self
.power_modal
.as_ref()
.is_some_and(|modal| modal.action == PowerAction::Logout(serial))
{
self.destroy_power_modal(false)
} else {
Task::none()
}
}
pub(super) fn execute_power_modal(&mut self) -> Task<Message> {
let Some(modal) = &mut self.power_modal else {
return Task::none();
};
if modal.motion.closing() || self.pending_power.is_some() {
return Task::none();
}
let action = match modal.action {
PowerAction::Logout(serial) => {
if let Some(control) = &self.control {
control.confirm_logout(serial);
return self.destroy_power_modal(false);
}
modal.error = Some("Ferese shell control is unavailable".to_owned());
return Task::none();
}
PowerAction::Poweroff => status::Action::Poweroff,
PowerAction::Reboot => status::Action::Reboot,
PowerAction::Suspend => status::Action::Suspend,
};
let id = modal.surfaces[0].id;
self.pending_power = Some((id, modal.action));
let destroy = self.destroy_power_modal(false);
let execute = Task::perform(
async move {
tokio::task::spawn_blocking(move || status::execute_power(action))
.await
.map_err(|error| error.to_string())
.and_then(|result| result)
},
move |result| cosmic::Action::App(Message::PowerCompleted(id, result)),
);
Task::batch([destroy, execute])
}
pub(super) fn finish_power_action(
&mut self,
id: window::Id,
result: Result<(), String>,
) -> Task<Message> {
let Some((pending_id, action)) = self.pending_power else {
return Task::none();
};
if id != pending_id {
return Task::none();
}
self.pending_power = None;
match result {
Ok(()) => Task::none(),
Err(error) => {
let task = self.open_power_modal(action, None);
if let Some(modal) = &mut self.power_modal {
modal.error = Some(error);
}
task
}
}
}
fn view_power_modal(&self, id: window::Id) -> Element<'_, cosmic::Action<Message>> {
let Some(modal) = &self.power_modal else {
return text("").into();
};
let Some(surface) = modal.surfaces.iter().find(|surface| surface.id == id) else {
return text("").into();
};
let progress = modal.motion.progress();
let content: Element<'_, cosmic::Action<Message>> = if surface.primary {
let theme = self.config.theme;
let material = surface.effects.is_some();
let alpha = if material { 1.0 } else { progress };
let mut palette = theme.palette();
palette.text = palette.text.scale_alpha(alpha);
palette.muted = palette.muted.scale_alpha(alpha);
palette.sidebar = palette.sidebar.scale_alpha(alpha);
palette.card = palette.card.scale_alpha(alpha);
palette.accent = palette.accent.scale_alpha(alpha);
let mut rows = column![
text(modal.action.title()).font(shell_font()).size(23),
text(modal.action.description())
.font(shell_font())
.size(14)
.class(theme::Text::Color(palette.muted)),
]
.spacing(12);
if let Some(error) = &modal.error {
rows = rows.push(text(error).size(13));
}
let confirm = button::text(modal.action.label())
.class(ferese_theme::controls::button_style(palette, true))
.on_press(cosmic::Action::App(Message::ExecutePower));
rows = rows.push(
row![
Space::new().width(Length::Fill),
button::text("Cancel")
.class(ferese_theme::controls::button_style(palette, false))
.on_press(cosmic::Action::App(Message::CancelPower)),
confirm,
]
.spacing(10),
);
container(rows)
.id("ferese-blur-card")
.width(Length::Fill)
.max_width(440)
.padding(24)
.class(theme::Container::custom(move |_| container::Style {
background: (!material).then_some(Background::Color(color_with_opacity(
theme.surface_popover,
alpha,
))),
text_color: Some(palette.text),
icon_color: Some(palette.text),
border: Border {
color: palette.muted.scale_alpha(0.16),
width: 1.0,
radius: theme.material_radius.into(),
},
..Default::default()
}))
.into()
} else {
text("").into()
};
let content = if surface.primary {
motion::animated(
content,
progress,
surface.regions.clone(),
self.config.theme.material_radius,
)
.into()
} else {
content
};
let centered = container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding(24)
.align_x(cosmic::iced::Alignment::Center)
.align_y(cosmic::iced::Alignment::Center)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color::from_rgba(
0.0,
0.0,
0.0,
0.34 * progress,
))),
..Default::default()
}));
centered.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_power_actions_require_confirmation() {
assert_eq!(
PowerAction::from_status(status::Action::Poweroff),
Some(PowerAction::Poweroff)
);
assert_eq!(
PowerAction::from_status(status::Action::Reboot),
Some(PowerAction::Reboot)
);
assert_eq!(
PowerAction::from_status(status::Action::Suspend),
Some(PowerAction::Suspend)
);
assert_eq!(
PowerAction::from_status(status::Action::Brightness(50)),
None
);
assert_eq!(PowerAction::Logout(1).label(), "Log out");
}
}