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.
429 lines
15 KiB
Rust
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(®ions, &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));
|
|
}
|
|
}
|