Files
feresaria/shell/ferese-shell/src/status_ui/lifecycle.rs
T
Samuel Onoja 65fcaef13f Add editable panels and refine shell settings (#18)
* Integrate panel composition, adaptive sizing, and overflow

* Add instance editing controls to panel settings

* Add panel group editing and measured composition preview

* Reorganize panel settings and add drag-and-drop item editing

* Simplify panel settings and place inspectors beside the editor

* Refine Panel & Shell settings, previews, and panel rendering

* Organize settings navigation and modernize packaged panel config

* Support top and bottom panels and refine shell surfaces

Add edge-aware settings, previews, popup placement, notifications, margins, and corner rendering. Preserve translucency in notification and dialog controls, and tighten spacing around the clock and display-mode icon.

* Add workspace styles and fix Settings text rendering

* Make panel allocation independent of measurement order

* Normalize panel item settings and visibility

* Move shell geometry into each panel

* Add independent group surfaces and finalize panel configuration

* Keep panel fit checks and restoration consistent with allocation

* Reject unknown panel item options and namespace metadata

* Reject invalid shell startup configuration and remove old radius fields

* Discover workspace application icons in a background task

* Remove the panel architecture document and improve reference prose

* Let panel media fill available space and improve Settings scrolling

* Preserve panel labels across clipped GPU rendering

* Use matching alpine lake wallpapers for light and dark modes

* Fix panel accessibility and popup lifecycle

Apply Reduce Transparency to panel opacity in the shell, compositor, and
Settings preview. Reject unknown theme tokens before snapshot substitution
and validate the current geometry fields in both consumers.

Dismiss popovers and workspace tooltips after panel layout changes. Handle
bar pointer exits directly and recover workspace hover after reentry without
repeated motion updates. Cover the transitions with regression tests and
update the wallpaper dimensions check for the bundled lake defaults.

* Use tagged Ferese toolkit fixes

* Fix center-only panel spacing and headless text rendering

* Bundle native 4K abstract wallpapers for both appearances

* Dismiss workspace tooltips before captured pointer dispatch

* Verify installed JPEG wallpapers when replacing a release
2026-10-10 18:50:37 +01:00

424 lines
14 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_config::panel::Edge) -> SctkPositioner {
// xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars.
let (anchor, gravity, gap) = match edge {
ferese_config::panel::Edge::Top => (2u32, 6u32, 8),
ferese_config::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 !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
.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();
}
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_config::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));
}
}