From d8a23748c82f618af5a3735fcfd1ca5f007ad77f Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 29 Sep 2026 22:21:15 +0100 Subject: [PATCH] Confirm power and logout actions in centered system modals --- compositor/src/input.rs | 2 +- compositor/src/ipc.rs | 1 + compositor/src/session_lock.rs | 1 + compositor/src/shell_control.rs | 94 +++++- compositor/src/state.rs | 4 + docs/backlog.md | 5 + docs/configuration.md | 2 + feresectl/src/main.rs | 4 +- protocols/ferese-shell-v1.xml | 17 +- shell/ferese-shell/src/control.rs | 46 ++- shell/ferese-shell/src/main.rs | 86 ++++- shell/ferese-shell/src/status.rs | 16 +- shell/ferese-shell/src/status_ui.rs | 28 +- shell/ferese-shell/src/system_modal.rs | 416 +++++++++++++++++++++++++ 14 files changed, 673 insertions(+), 49 deletions(-) create mode 100644 docs/backlog.md create mode 100644 shell/ferese-shell/src/system_modal.rs diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 011a73d..eb3fc09 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -864,7 +864,7 @@ impl Ferese { } } BindingAction::Close => self.close_focused_window(), - BindingAction::Exit => self.loop_signal.stop(), + BindingAction::Exit => self.request_logout_confirmation(), BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 49d80c6..f2aa9c7 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -321,6 +321,7 @@ impl Ferese { fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result { match command { "exit" => {} // The IPC worker stops the loop after writing the response. + "request-logout" => self.request_logout_confirmation(), "reload-config" => self .reload_config() .map_err(|e| CommandError::new("invalid_config", e))?, diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index fdc143c..52aa8ad 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -121,6 +121,7 @@ impl SessionLockHandler for Ferese { if self.session_lock.active { return; } + self.cancel_logout_confirmation(); self.session_lock.active = true; self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.confirmation = Some(confirmation); diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 9444c9f..6f833b0 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -14,7 +14,7 @@ use smithay::{ use crate::{Ferese, private_client::ClientCapabilities, state::ClientState}; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(2, ()); + display.create_global::(3, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -136,6 +136,21 @@ impl Dispatch for Ferese { ); } } + ferese_shell_v1::Request::ConfirmLogout { serial } => { + if state.logout_owner.as_ref() == Some(&shell.id()) + && consume_logout_confirmation(&mut state.pending_logout, serial) + { + state.logout_owner = None; + state.loop_signal.stop(); + } + } + ferese_shell_v1::Request::CancelLogout { serial } => { + if state.logout_owner.as_ref() == Some(&shell.id()) + && state.pending_logout == Some(serial) + { + state.cancel_logout_confirmation(); + } + } ferese_shell_v1::Request::EnterOverview => state.set_overview_active(true), ferese_shell_v1::Request::ExitOverview => state.set_overview_active(false), ferese_shell_v1::Request::SelectOverviewWindow { @@ -152,13 +167,78 @@ impl Dispatch for Ferese { ); } } - ferese_shell_v1::Request::Destroy => {} + ferese_shell_v1::Request::Destroy => { + if state.logout_owner.as_ref() == Some(&shell.id()) { + state.cancel_logout_confirmation(); + } + } _ => unreachable!(), } } + + fn destroyed( + state: &mut Self, + _client: smithay::reexports::wayland_server::backend::ClientId, + shell: &FereseShellV1, + _data: &(), + ) { + if state.logout_owner.as_ref() == Some(&shell.id()) { + state.pending_logout = None; + state.logout_owner = None; + } + } +} + +fn consume_logout_confirmation(pending: &mut Option, serial: u32) -> bool { + if *pending == Some(serial) { + *pending = None; + true + } else { + false + } } impl Ferese { + pub(crate) fn request_logout_confirmation(&mut self) { + if self.session_lock.active { + return; + } + let shells = self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + .filter(|shell| shell.version() >= 3) + .collect::>(); + if shells.is_empty() { + tracing::warn!("logout confirmation requires the updated Ferese shell"); + return; + } + self.cancel_logout_confirmation(); + let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial()); + let shell = &shells[0]; + self.pending_logout = Some(serial); + self.logout_owner = Some(shell.id()); + let output_name = self + .focused_output() + .map_or_else(String::new, |output| output.name()); + shell.logout_requested(serial, output_name); + } + + pub(crate) fn cancel_logout_confirmation(&mut self) { + if let Some(serial) = self.pending_logout.take() { + for shell in self + .shell_resources + .iter() + .filter_map(|shell| shell.upgrade().ok()) + { + if self.logout_owner.as_ref() == Some(&shell.id()) && shell.version() >= 3 { + shell.logout_cancelled(serial); + } + } + } + self.logout_owner = None; + } + pub(crate) fn send_shell_snapshots(&mut self) { self.shell_resources .retain(|resource| resource.upgrade().is_ok()); @@ -427,6 +507,16 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe #[cfg(test)] mod tests { + #[test] + fn logout_requires_the_current_confirmation_and_can_be_cancelled() { + let mut pending = Some(42); + assert!(!super::consume_logout_confirmation(&mut pending, 41)); + assert_eq!(pending, Some(42)); + assert!(super::consume_logout_confirmation(&mut pending, 42)); + assert_eq!(pending, None); + assert!(!super::consume_logout_confirmation(&mut pending, 42)); + } + use super::*; #[test] diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0141e7c..ad9687c 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -257,6 +257,8 @@ pub struct Ferese { pub(crate) overview_scrims: HashMap, pub(crate) material_programs: HashMap, pub(crate) material_buffers: HashMap, + pub(crate) pending_logout: Option, + pub(crate) logout_owner: Option, pub(crate) blur_programs: HashMap, pub(crate) backdrop_generation: u64, closing_windows: HashMap, @@ -478,6 +480,8 @@ impl Ferese { overview_scrims: HashMap::new(), material_programs: HashMap::new(), material_buffers: HashMap::new(), + pending_logout: None, + logout_owner: None, blur_programs: HashMap::new(), backdrop_generation: 0, closing_windows: HashMap::new(), diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..17b475f --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,5 @@ +# Backlog + +- [ ] Keep overview open when selecting a workspace and show all of that workspace’s windows. Activate a window only after the user selects it. + +- [ ] Add a first-run keybinding guide modal showing important keybindings and their functions. Keep showing it until the user disables the guide in Settings. diff --git a/docs/configuration.md b/docs/configuration.md index a7fa5ae..8447690 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -454,3 +454,5 @@ Settings → Windows. `theme.geometry.shell_radius` takes precedence over the ol `appearance.corner_radius` and then `theme.geometry.top_bar_radius` keys; when none are set, the shell uses 14 px. Legacy clock/note radius fields no longer override shell rounding. + +Power actions use a centered system confirmation. The `exit` key binding (Super+Shift+E by default) requests logout confirmation; `feresectl request-logout` does the same. `feresectl exit` remains an immediate administrative exit. diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 1e88f4e..d74a14e 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -89,7 +89,7 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" | "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config" - | "exit" => { + | "exit" | "request-logout" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -118,7 +118,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index ebd239d..bbaba51 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -143,5 +143,18 @@ + + + + + + + + + + + + + diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 4380df9..4b4bc81 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -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, pub(crate) overview_active: Option, pub(crate) disconnected: bool, + pub(crate) logout: Option<(u32, String)>, + pub(crate) logout_cancelled: Vec, } 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::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 1..=2, ())?; + let manager = globals.bind::(&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 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)); } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 0c05dba..419cb49 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -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, menu: Option, + power_modal: Option, + pending_power: Option<(window::Id, system_modal::PowerAction)>, note_editor: Option, note_drag: Option, note_pointer: std::collections::HashMap, @@ -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 { 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> { + 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> { 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()); diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index b5e1014..d60a3d3 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -254,13 +254,17 @@ pub fn available(program: &str) -> bool { } fn run(program: &str, args: &[&str]) -> Result { + run_with_timeout(program, args, "2s") +} + +fn run_with_timeout(program: &str, args: &[&str], timeout: &str) -> Result { 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 { 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" }]), diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 88441e3..0633dde 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -71,7 +71,6 @@ pub struct OpenMenu { pub id: window::Id, pub kind: Menu, pub motion: super::motion::PopupMotion, - pub confirm: Option, pub effects: Option, 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(), }; diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs new file mode 100644 index 0000000..da8da96 --- /dev/null +++ b/shell/ferese-shell/src/system_modal.rs @@ -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 { + 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, + regions: motion::Regions, +} + +pub(super) struct PowerModal { + action: PowerAction, + surfaces: Vec, + pub(super) motion: motion::PopupMotion, + error: Option, +} + +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 { + 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::>(); + 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::( + |_| 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(®ions, ferese_surface_effects_v1::Role::Modal); + let _ = effects.set_opacity(modal.motion.progress()); + } + } + } + } + + pub(super) fn close_power_modal(&mut self) -> Task { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> = 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"); + } +}