Files
feresaria/shell/ferese-shell/src/status_ui/lifecycle.rs
T
Samuel Onoja 7d58eeb65d Add named keyboard binding modes (#32)
Add named keyboard shortcut maps with an Escape exit and a panel indicator. Keep press/release ownership across transitions, validate remapped Escape keys, and cancel stale repeats and focus previews.

Give the indicator ownership of the panel while a mode is active. Close popovers, collapse Morph, and keep Live Activities data current without resizing the hidden panel. Document configuration and transition behavior.

Validation: 1,519 workspace Rust tests passed; 70 Python tests passed. Workspace build, formatting, Clippy, and Settings configuration validation passed. Opt-in native desktop checks were not rerun because Weston is absent.

Related: #5.
2026-10-12 23:22:02 +01:00

429 lines
15 KiB
Rust

use std::time::Instant;
use cosmic::app::Task;
use cosmic::iced::Rectangle;
use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner};
use cosmic::iced::{Limits, window};
use wayland_client::Proxy;
use super::{Menu, OpenMenu, PopoverAnchor};
use crate::status::Action;
use crate::{FereseShell, Message};
fn menu_positioner(rectangle: Rectangle<i32>, height_limit: f32, edge: ferese_panel::Edge) -> SctkPositioner {
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
let (anchor, gravity, gap) = match edge {
ferese_panel::Edge::Top => (2u32, 6u32, 8),
ferese_panel::Edge::Bottom => (1u32, 5u32, -8),
};
SctkPositioner {
anchor_rect: rectangle,
anchor: anchor.try_into().unwrap(),
gravity: gravity.try_into().unwrap(),
offset: (rectangle.width / 2, gap),
size_limits: Limits::NONE.max_width(368.0).max_height(720.0_f32.max(height_limit)),
constraint_adjustment: 3,
..Default::default()
}
}
impl OpenMenu {
fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) {
self.kind = kind;
// Keep the Wayland popup and its material binding, but give the new
// panel its own entrance instead of reusing the settled old panel.
self.motion = crate::motion::PopupMotion::new(settings);
self.motion.begin(now);
}
}
impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task<Message> {
if (self.snapshot.binding_mode.is_some() && anchor.item.is_some())
|| !anchor.belongs_to(&self.config.panels[0])
|| !self.outputs.iter().any(|output| output.bar == anchor.parent)
|| self.bar_hidden(anchor.parent)
|| !kind.available(&self.status)
{
return Task::none();
}
if self.panel_for_bar(anchor.parent).style == ferese_panel::PanelStyle::Morph {
return self.open_hub(anchor.parent, kind);
}
if self
.menu
.as_ref()
.is_some_and(|menu| menu.anchor.parent != anchor.parent)
{
let close = self.destroy_menu();
let open = self.open_menu(kind, anchor);
return close.chain(open);
}
if self.menu.as_ref().is_some_and(|menu| {
menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle
}) {
if let Some(menu) = &mut self.menu
&& menu.motion.closing()
{
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
}
menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
return self.close_menu();
}
if kind == Menu::Media {
self.media.now_us = ferese_ipc::media::now_us();
self.media.refresh_art(true);
self.media.error = None;
}
if kind != Menu::Media {
self.refresh_media_art(false);
}
if kind == Menu::Calendar {
self.calendar_offset = 0;
}
if !kind.available(&self.status) {
return Task::none();
}
if self.menu.as_ref().is_some_and(|menu| menu.kind == Menu::Notifications) {
self.notifications.history_open = false;
self.notifications.hovered = None;
}
if kind == Menu::Notifications && self.notifications.ready {
self.notifications.toggle_history();
self.sync_activity_presentations();
}
let notifications = self.sync_notification_surface();
crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed);
self.status_error = None;
let parent = anchor.parent;
let rectangle = anchor.rectangle;
let height_limit = if kind == Menu::Notifications {
self.notification_history_height_limit()
} else {
kind.height_limit()
};
let positioner = menu_positioner(rectangle, height_limit, self.config.panels[0].edge);
if let Some(menu) = &mut self.menu {
menu.anchor = anchor;
menu.switch_panel(kind, self.config.animations, Instant::now());
return Task::batch([
notifications,
cosmic::iced::platform_specific::shell::commands::popup::reposition(menu.id, positioner),
]);
}
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
anchor,
kind,
motion: crate::motion::PopupMotion::new(self.config.animations),
effects: None,
regions: Default::default(),
});
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: positioner.clone(),
},
Some(Box::new(Self::view_status_menu)),
);
notifications.chain(cosmic::task::message(cosmic::Action::Surface(action)))
}
pub fn destroy_menu(&mut self) -> Task<Message> {
let tooltip = self.dismiss_workspace_tooltip();
crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
let Some(menu) = self.menu.take() else {
return tooltip;
};
self.refresh_media_art(false);
if menu.kind == Menu::Notifications {
self.notifications.history_open = false;
self.notifications.hovered = None;
}
Task::batch([
tooltip,
cosmic::task::message(cosmic::Action::Surface(cosmic::surface::action::destroy_popup(menu.id))),
self.sync_notification_surface(),
])
}
pub fn close_menu(&mut self) -> Task<Message> {
self.refresh_media_art(false);
let Some(menu) = &mut self.menu else {
return Task::none();
};
if menu.motion.closing() {
return Task::none();
}
if menu
.effects
.as_ref()
.is_some_and(|effects| effects.surface.version() >= 3)
{
menu.motion.retarget(0.0, Instant::now());
if menu.animating() {
return Task::none();
}
}
self.destroy_menu()
}
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu
&& menu.motion.closing()
&& !menu.animating()
{
return self.destroy_menu();
}
Task::none()
}
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;
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::{PopupMotion, Settings};
use std::time::Duration;
fn test_anchor() -> PopoverAnchor {
PopoverAnchor {
parent: window::Id::unique(),
panel: crate::panel::PanelId("main".into()),
item: Some(crate::panel::ItemId("battery".into())),
rectangle: Rectangle::default(),
}
}
#[test]
fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() {
let mut shell = crate::tests::shell_with_measured_panel();
shell.status.network = Some(crate::status::Network {
enabled: true,
connection: Some("Test network".into()),
signal: 90,
});
let bar = shell.outputs[0].bar;
let mut anchor = PopoverAnchor {
parent: bar,
panel: shell.config.panels[0].id.clone(),
item: Some(crate::panel::ItemId("network".into())),
rectangle: Rectangle {
x: 1000,
y: 0,
width: 60,
height: 36,
},
};
drop(shell.open_menu(Menu::Network, anchor.clone()));
let popup = shell.menu.as_ref().unwrap().id;
anchor.rectangle.x = 800;
drop(shell.open_menu(Menu::Network, anchor.clone()));
let menu = shell.menu.as_ref().unwrap();
assert_eq!(menu.id, popup);
assert_eq!(menu.anchor, anchor);
assert!(!menu.motion.closing());
drop(shell.open_menu(Menu::Network, anchor));
assert!(shell.menu.is_none());
}
#[test]
fn menus_open_toward_the_desktop_from_either_panel_edge() {
use ferese_panel::Edge;
let rectangle = Rectangle {
x: 400,
y: 0,
width: 32,
height: 30,
};
for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] {
let positioner = menu_positioner(rectangle, 620., edge);
assert_eq!(positioner.anchor_rect, rectangle);
assert_eq!(positioner.anchor, anchor.try_into().unwrap());
assert_eq!(positioner.gravity, gravity.try_into().unwrap());
assert_eq!(positioner.offset, (16, gap));
}
}
#[test]
fn popup_identity_includes_parent_panel_and_instance_but_not_rectangle() {
let first = test_anchor();
let mut second = first.clone();
second.rectangle.x = 100;
assert!(first.same_item(&second));
second.item = Some(crate::panel::ItemId("other-battery".into()));
assert!(!first.same_item(&second));
second = first.clone();
second.parent = window::Id::unique();
assert!(!first.same_item(&second));
second = first.clone();
second.panel = crate::panel::PanelId("other".into());
assert!(!first.same_item(&second));
}
#[test]
fn switching_panels_replays_entrance_on_the_same_surface() {
let now = Instant::now();
let settings = Settings::default();
let mut menu = OpenMenu {
id: window::Id::unique(),
anchor: test_anchor(),
kind: Menu::Battery,
motion: PopupMotion::new(settings),
effects: None,
regions: Default::default(),
};
let id = menu.id;
let regions = menu.regions.clone();
menu.motion.begin(now);
for (index, kind) in [
Menu::Network,
Menu::Bluetooth,
Menu::Audio,
Menu::Calendar,
Menu::Recording,
Menu::Notifications,
Menu::System,
Menu::Battery,
]
.into_iter()
.enumerate()
{
let at = now + Duration::from_secs(10 * (index as u64 + 1));
assert_eq!(menu.motion.progress_at(at), 1.0);
menu.switch_panel(kind, settings, at);
assert_eq!(menu.kind, kind);
assert_eq!(menu.id, id);
assert!(std::sync::Arc::ptr_eq(&regions, &menu.regions));
assert_eq!(menu.motion.progress_at(at), 0.0);
assert!(menu.motion.frame_active(at));
let progress = menu.motion.progress_at(at + Duration::from_millis(30));
assert!(progress > 0.0 && progress < 1.0);
}
let at = now + Duration::from_secs(90);
menu.motion.retarget(0.0, at);
menu.switch_panel(Menu::Network, settings, at + Duration::from_millis(30));
assert!(!menu.motion.closing());
assert!(menu.motion.frame_active(at + Duration::from_millis(30)));
}
#[test]
fn panel_switch_respects_reduced_motion_and_speed() {
let now = Instant::now();
let mut menu = OpenMenu {
id: window::Id::unique(),
anchor: test_anchor(),
kind: Menu::Battery,
motion: PopupMotion::new(Settings::default()),
effects: None,
regions: Default::default(),
};
for settings in [
Settings {
reduced_motion: true,
..Default::default()
},
Settings {
enabled: false,
..Default::default()
},
] {
menu.switch_panel(Menu::Network, settings, now);
assert_eq!(menu.motion.progress_at(now), 1.0);
assert!(!menu.motion.frame_active(now));
}
menu.switch_panel(Menu::Battery, Settings::default(), now);
let normal = menu.motion.progress_at(now + Duration::from_millis(30));
menu.switch_panel(
Menu::Network,
Settings {
speed: 0.5,
..Default::default()
},
now,
);
assert_eq!(menu.motion.progress_at(now + Duration::from_millis(60)), normal);
}
#[test]
fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() {
let mut panel = crate::panel::Panel::default();
let anchor = PopoverAnchor {
parent: window::Id::unique(),
panel: panel.id.clone(),
item: Some(crate::panel::ItemId("network".into())),
rectangle: Rectangle {
x: 0,
y: 0,
width: 20,
height: 20,
},
};
assert!(anchor.belongs_to(&panel));
panel.end.groups[0].items[2].visible = false;
assert!(!anchor.belongs_to(&panel));
panel.end.groups[0].items.remove(2);
assert!(!anchor.belongs_to(&panel));
panel.id = crate::panel::PanelId("other".into());
let fallback = PopoverAnchor {
item: None,
..anchor.clone()
};
assert!(!fallback.belongs_to(&panel));
let fallback = PopoverAnchor {
panel: panel.id.clone(),
..fallback
};
assert!(fallback.belongs_to(&panel));
}
}