mod bar; mod surfaces; use bar::*; use surfaces::*; mod clock; mod compositor_ipc; mod config; mod control; mod display_mode; mod keybinding_guide; mod media; mod motion; mod notification_ui; mod notifications; mod panel; mod panel_layout; mod recording; mod renderer; mod status; mod status_ui; mod system_modal; mod workspace_ui; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::event::{PlatformSpecific, wayland}; use cosmic::iced::platform_specific::runtime::wayland::layer_surface::{ IcedMargin, IcedOutput, SctkLayerSurfaceSettings, }; use cosmic::iced::platform_specific::shell::commands::layer_surface::{ Anchor, KeyboardInteractivity, Layer, destroy_layer_surface, set_anchor, set_exclusive_zone, set_input_zone, set_margin, set_size, }; use cosmic::iced::{ Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, alignment, event, window, }; use cosmic::widget::{button, container, icon, image, row}; use cosmic::{Element, theme}; use ferese_protocols::effects::v1::client::ferese_effects_manager_v1::FereseEffectsManagerV1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1::FereseSurfaceEffectsV1; use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon}; use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::{wl_output, wl_registry, wl_surface}; use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop}; use crate::config::{ShellConfig, ShellTheme, WallpaperMode}; use crate::control::{ShellControl, ShellSnapshot}; const APP_ID: &str = "dev.ferese.Shell"; // Older libcosmic backends expose native surfaces in frame events. Keep // that compatibility path gated; current backends use Window::Opened and // window::run to retrieve handles without updates at the display refresh rate. static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true); static SHELL_FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); fn configured_font(family: Option<&str>) -> cosmic::font::Font { ferese_theme::font(family) } fn shell_font() -> cosmic::font::Font { *SHELL_FONT .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) .read() .unwrap() } fn text<'a>( content: impl Into> + 'a, ) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { ferese_theme::text(content, shell_font()) } #[derive(Clone, Copy)] struct BarMetrics { height: f32, text_size: u16, icon_size: u16, overview_icon_size: u16, control_height: f32, group_height: f32, group_item_height: f32, } impl From for BarMetrics { fn from(geometry: panel::PanelGeometry) -> Self { let height = geometry.height; let control_height = (height - 4.0).max(21.0).min(height); // Leave two logical pixels inside the group border on each side, // including compact bars with 20 px icons. let group_height = control_height.max(24.0).min(height); Self { height, // Logical sizes: Wayland/iced applies each surface's output scale. // Compacting the bar must not also shrink its readable content. text_size: 14, icon_size: 20, overview_icon_size: 20, control_height, group_height, group_item_height: (group_height - 4.0).max(0.0), } } } fn main() -> cosmic::iced::Result { let mut config = config::load().unwrap_or_else(|error| { eprintln!("ferese-shell: {error}; shell was not started"); std::process::exit(2); }); renderer::configure_shell(); motion::configure(config.animations, config.theme.material_radius); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); if compositor_wallpaper { // The compositor owns one GPU image, shared across output renderers; // do not decode another copy or allocate full-screen software buffers. config.wallpaper.path = None; } let shell_font = configured_font(config.font_family.as_deref()); let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font)); // Decode alongside toolkit/GPU initialization, never during a UI draw. let wallpaper = config.wallpaper.path.clone().map(|path| { let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel(); std::thread::spawn(move || { let result = cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)) .map(|pixels| image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw())) .map_err(|error| format!("{error:?}")); let _ = sender.send(result); }); receiver }); let mut settings = Settings::default() .no_main_window(true) .client_decorations(false) .transparent(true) .is_daemon(true); settings = settings .default_font(shell_font) .theme(config.theme.palette().native_theme()); cosmic::app::run::(settings, (config, wallpaper)) } type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver>; struct FereseShell { core: Core, config: ShellConfig, wallpaper: Option, control: Option, snapshot: ShellSnapshot, workspace_ui: workspace_ui::WorkspaceUi, overview_active: bool, clock: String, outputs: Vec, notifications: notifications::Center, notification_surface: Option, status_service: status::Service, status: status::Snapshot, recorder: recording::Recorder, media: media::Model, calendar_offset: i32, status_error: Option, theme_error: Option, menu: Option, system_modal: Option, display_mode: display_mode::Model, guide_shown: bool, guide_load: keybinding_guide::LoadState, guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, } struct OutputSurfaces { output: wl_output::WlOutput, name: Option, bar: window::Id, wallpaper: Option, size: Option<(i32, i32)>, effects: Option, bar_regions: Vec<[f32; 5]>, panel_resolution: Option, bar_margin_horizontal: i32, hidden: bool, } #[derive(Clone, Debug)] enum Message { BarRegionsChanged(window::Id, Vec<[f32; 5]>), PanelResolved(window::Id, panel_layout::Resolution), ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), ThemeModeSet(Result<(), String>), WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>), ClockChanged(String), RecorderEvent(recording::Event), RecorderElapsed, MediaUpdated(std::sync::Arc), MediaAction(serde_json::Value), MediaCompleted(Result<(), String>), MediaTick, MediaChoose, MediaSeek(u64), MediaSeekCommit, MediaArt(media::Artwork), ControlReady, ActivateWorkspace(u64), HoverWorkspace(window::Id, u64, cosmic::iced::advanced::widget::Id, bool), WorkspaceTooltipDelay(u64), WorkspaceTooltipBounds(u64, Option), WorkspaceIconsLoaded(Result), ToggleOverview, StatusUpdated(status::Update), StartRecording, StopRecording, NotificationEvent(notifications::Event), NotificationTick, DismissNotification(u32), RemoveNotification(u32), InvokeNotification(u32, String), HoverNotification(u32, bool), ToggleNotificationHistory, ClearNotifications, ToggleNotificationGroup(String), RemoveNotificationGroup(String), AnimateMenu, PanelAction(Box), OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenPopover(status_ui::Menu, status_ui::PopoverAnchor), Control(status::Action), ShowGuide, GuideLoaded { generation: u64, manual: bool, output: Option, result: Result, String>, }, OpenDisplays(Option), DisplaysLoaded(u64, Result), SelectDisplayMode(display_mode::Mode), ApplyDisplayMode, DisplayModeCompleted(u64, bool, Result), KeepDisplayMode, RevertDisplayMode, SystemInhibitors(window::Id, Result), ConfirmPower(status::Action), CancelPower, ExecutePower, AnimatePower, PowerCompleted(window::Id, Result<(), String>), CalendarMonth(i32), CalendarToday, } impl cosmic::Application for FereseShell { type Executor = cosmic::executor::Default; type Flags = (ShellConfig, Option); type Message = Message; const APP_ID: &'static str = APP_ID; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { let app = Self { core, notifications: notifications::Center::new(config.notifications.clone()), notification_surface: None, status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), recorder: recording::Recorder::default(), media: media::Model::default(), calendar_offset: 0, status_error: None, theme_error: None, menu: None, system_modal: None, display_mode: display_mode::Model::default(), guide_shown: false, guide_load: Default::default(), guide_attempts: 0, pending_power: None, config, wallpaper: None, control: ShellControl::connect() .map_err(|error| { eprintln!("ferese-shell: shell control unavailable: {error}"); }) .ok(), snapshot: ShellSnapshot::default(), workspace_ui: Default::default(), overview_active: false, clock: clock::current_time(), outputs: Vec::new(), }; let wallpaper_task = match wallpaper { Some(receiver) => cosmic::task::future(async move { cosmic::Action::App(Message::WallpaperLoaded( receiver.await.unwrap_or_else(|error| Err(error.to_string())), )) }), None => Task::none(), }; (app, wallpaper_task) } fn subscription(&self) -> Subscription { Subscription::batch([ self.control.as_ref().map_or_else(Subscription::none, |control| { control.subscription().map(|_| Message::ControlReady) }), ferese_theme_client::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), self.notifications.subscription().map(Message::NotificationEvent), self.notifications .tick_subscription() .map(|_| Message::NotificationTick), event::listen_with(|event, status, id| match &event { Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), .. }) if status == cosmic::iced::event::Status::Captured => None, Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), Event::Keyboard(_) | Event::Mouse(cosmic::iced::mouse::Event::ButtonPressed(_) | cosmic::iced::mouse::Event::CursorLeft) | Event::Window(window::Event::Opened { .. } | window::Event::Closed) | Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::Popup(..) | wayland::Event::Layer(..) | wayland::Event::Output(..), )) => Some(Message::Event(event, id)), _ => None, }), clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged), self.media.subscription().map(Message::MediaUpdated), self.media_tick_subscription().map(|_| Message::MediaTick), self.media.art.subscription().map(Message::MediaArt), self.recorder.subscription().map(Message::RecorderEvent), self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed), self.status_service.subscription().map(Message::StatusUpdated), ]) } fn update(&mut self, message: Self::Message) -> Task { match message { Message::PanelResolved(id, resolution) => { let allocation_changed = self .outputs .iter() .find(|output| output.bar == id) .is_some_and(|output| { output .panel_resolution .as_ref() .is_some_and(|previous| previous != &resolution) }); let close_menu = self.menu.as_ref().is_some_and(|menu| { menu.anchor.parent == id && menu.anchor.item.is_some() && ((menu.kind == status_ui::Menu::Overflow && resolution.overflow.is_empty()) || allocation_changed) }); if let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) { output.panel_resolution = Some(resolution); } let margin = self.update_panel_margin(id, false); let tooltip = if allocation_changed { self.dismiss_workspace_tooltip_for_bar(id) } else { Task::none() }; if close_menu { Task::batch([margin, tooltip, self.destroy_menu()]) } else { Task::batch([margin, tooltip]) } } Message::BarRegionsChanged(id, regions) => { let Some(output) = self.outputs.iter_mut().find(|output| output.bar == id) else { return Task::none(); }; output.bar_regions = regions; set_input_zone( id, bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions), ) } Message::MediaUpdated(snapshot) => { self.media.receive(snapshot); self.refresh_media_art( self.menu .as_ref() .is_some_and(|menu| menu.kind == status_ui::Menu::Media && !menu.motion.closing()), ); Task::none() } Message::MediaAction(args) => self.media.command(args), Message::MediaCompleted(result) => self.media.completed(result), Message::MediaTick => { self.media.now_us = ferese_ipc::media::now_us(); Task::none() } Message::MediaChoose => { self.media.choosing = !self.media.choosing; Task::none() } Message::MediaSeek(position) => { self.media.seeking = Some(position); Task::none() } Message::MediaSeekCommit => { let Some(position) = self.media.seeking.take() else { return Task::none(); }; let mut args = self.media.action("seek"); args["position_us"] = serde_json::json!(position); args["track_id"] = serde_json::json!(self.media.snapshot.selected.as_ref().and_then(|p| p.track_id.clone())); self.media.command(args) } Message::MediaArt(art) => { if self.media.art.accepts(&art) { self.media.artwork = Some(art); } Task::none() } Message::ThemeChanged(snapshot) => { let mut config = self.config.clone(); config.apply_theme(&snapshot.presented); config.theme_mode = snapshot.mode; if snapshot.error != self.theme_error { if let Some(error) = &snapshot.error { self.notifications.service_error("Theme could not be loaded", error); } self.theme_error = snapshot.error; } self.apply_config(config) } Message::ThemeMode(mode) => cosmic::task::future(async move { let result = tokio::task::spawn_blocking(move || { let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?; connection .call("theme-set-mode", serde_json::json!({"mode": mode})) .map(|_| ()) }) .await .unwrap_or_else(|e| Err(e.to_string())); cosmic::Action::App(Message::ThemeModeSet(result)) }), Message::ThemeModeSet(result) => { if let Err(error) = result { self.notifications .service_error("Appearance could not be changed", &error); } Task::none() } Message::WallpaperLoaded(result) => { match result { Ok(handle) => self.wallpaper = Some(handle), Err(error) => eprintln!("ferese-shell: wallpaper unavailable: {error}"), } Task::none() } Message::NativeSurface(id, result) => { match result { Ok((_connection, surface)) if self.outputs.iter().any(|output| output.bar == id) => { 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() { match EffectsBinding::attach_role(&surface, None, 1.0) { Ok(binding) => entry.effects = Some(binding), Err(error) => eprintln!("ferese-shell: notification effects unavailable: {error}"), } } if let Some(menu) = &mut self.menu && menu.id == id { menu.prepare_surface(&surface); } } Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"), } Task::none() } Message::StartRecording => { self.recorder.start(); if self.recorder.busy() { self.close_menu() } else { Task::none() } } Message::StopRecording => { self.recorder.stop(); Task::none() } Message::StatusUpdated(update) => { if update.generation >= self.status_service.generation { self.status = update.snapshot; self.status_error = update.error; } self.notifications.tick(); if self.notifications.ready { self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), dnd: self.notifications.dnd, }); } self.sync_notification_surface() } Message::NotificationEvent(event) => { self.notifications.handle_event(event); if self.notifications.ready { self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), dnd: self.notifications.dnd, }); } self.sync_notification_surface() } Message::NotificationTick => { self.notifications.tick(); self.sync_notification_surface() } Message::DismissNotification(id) => { self.notifications.close(id, 2); self.sync_notification_surface() } Message::RemoveNotification(id) => { self.notifications.dismiss(id); self.sync_notification_surface() } Message::InvokeNotification(id, action) => { self.notifications.invoke(id, action); self.sync_notification_surface() } Message::HoverNotification(id, hovered) => { self.notifications.hover(id, hovered); self.sync_notification_surface() } Message::ToggleNotificationHistory => self.toggle_notification_history(), Message::ToggleNotificationGroup(app) => { self.notifications.toggle_group(app); self.sync_notification_surface() } Message::RemoveNotificationGroup(app) => { self.notifications.dismiss_group(&app); self.sync_notification_surface() } Message::ClearNotifications => { self.notifications.clear(); self.sync_notification_surface() } Message::AnimateMenu => self.animate_menu(), Message::PanelAction(message) => { let close = self.destroy_menu(); let action = self.update(*message); close.chain(action) } Message::OpenMenu(..) => Task::none(), // Bar views attach an explicit item/surface anchor. Message::OpenPopover(kind, anchor) => { let tooltip = self.dismiss_workspace_tooltip(); Task::batch([tooltip, self.open_menu(kind, anchor)]) } Message::ShowGuide => { if self.guide_shown || self.guide_load.loading || self.guide_attempts >= 5 || !self.config.status.keybinding_guide || self.outputs.is_empty() { return Task::none(); } self.guide_attempts += 1; self.load_guide(false, None) } Message::GuideLoaded { generation, manual, output, result, } => { if !self.guide_load.finish(generation) { return Task::none(); } match result { Ok(entries) if (manual || self.config.status.keybinding_guide) && !self.outputs.is_empty() && self.system_modal.is_none() && self.pending_power.is_none() => { self.guide_shown = true; self.open_guide_on(entries, output.as_deref()) } result => { if let Err(error) = result { eprintln!("ferese-shell: keybinding guide unavailable: {error}"); } if !manual && self.guide_attempts < 5 && self.config.status.keybinding_guide && !self.guide_shown { Task::perform( async { tokio::time::sleep(Duration::from_secs(2)).await; }, |_| cosmic::Action::App(Message::ShowGuide), ) } else { Task::none() } } } } Message::SystemInhibitors(id, result) => self.set_system_inhibitors(id, result), Message::ConfirmPower(action) => { if let Some(action) = system_modal::PowerAction::from_status(action) { return self.open_system_modal(action, None); } Task::none() } Message::OpenDisplays(output) => self.open_display_modal(output.as_deref()), Message::DisplaysLoaded(generation, result) => self.finish_display_inventory(generation, result), Message::SelectDisplayMode(mode) => self.select_display_mode(mode, true), Message::ApplyDisplayMode => self.apply_display_mode(), Message::DisplayModeCompleted(serial, close, result) => self.finish_display_mode(serial, close, result), Message::KeepDisplayMode => self.confirm_display_mode(true), Message::RevertDisplayMode => self.confirm_display_mode(false), Message::ExecutePower => self.execute_system_modal(), Message::AnimatePower => self.animate_system_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() } Message::CalendarToday => { self.calendar_offset = 0; Task::none() } Message::CancelPower => self.close_system_modal(), Message::Control(action) => { if let Some(action) = system_modal::PowerAction::from_status(action.clone()) { return self.open_system_modal(action, None); } if let status::Action::PowerProfile(profile) = action { self.status_error = self.status_service.send(status::Action::PowerProfile(profile)).err(); if self.status_error.is_none() && let Some(profiles) = &mut self.status.power_profiles { profiles.active = profile.to_owned(); } return Task::none(); } if self.notifications.ready { match action { status::Action::Dnd(value) => { self.notifications.dnd = value; self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), dnd: value, }); return Task::none(); } status::Action::Notifications => { return Task::batch([self.close_menu(), self.toggle_notification_history()]); } _ => {} } } self.status_error = self.status_service.send(action.clone()).err(); if self.status_error.is_none() { self.optimistic_status(&action); } if matches!(action, status::Action::Notifications | status::Action::Settings) { return self.close_menu(); } Task::none() } Message::Event(event, id) => self.handle_event(event, id), Message::ClockChanged(labels) => { self.clock = labels; Task::none() } Message::RecorderEvent(event) => { self.recorder.handle(event); Task::none() } Message::RecorderElapsed => Task::none(), Message::ControlReady => { let mut reload_task = Task::none(); if let Some(control) = &self.control { let poll = control.poll(); if poll.disconnected { return cosmic::iced::exit(); } if let Some(active) = poll.overview_active { self.overview_active = active; if active { reload_task = self.dismiss_workspace_tooltip(); } } if let Some(source) = poll.config { reload_task = Task::batch([reload_task, self.reload_config(source)]); } if let Some(snapshot) = poll.snapshot { self.snapshot = snapshot; let mut tasks = vec![reload_task, self.update_workspace_ui()]; let mut visibility_changed = false; for entry in &mut self.outputs { let output = self .snapshot .outputs .iter() .find(|output| Some(output.name.as_str()) == entry.name.as_deref()) .map(|output| output.id); let hidden = output_bar_hidden(&self.snapshot, output); if entry.hidden == hidden { continue; } entry.hidden = hidden; visibility_changed = true; if let Some(effects) = &entry.effects && let Err(error) = effects.set_visible(!hidden) { eprintln!("ferese-shell: could not update panel material: {error}"); } tasks.push(set_input_zone( entry.bar, bar::input_region(self.config.panels[0].surface, hidden, &entry.bar_regions), )); } if self .menu .as_ref() .is_some_and(|menu| self.bar_hidden(menu.anchor.parent)) { tasks.push(self.destroy_menu()); } if visibility_changed { tasks.push(self.dismiss_workspace_tooltip()); self.refresh_media_art(false); } reload_task = Task::batch(tasks); } for command in poll.commands { let task = match command { control::ControlCommand::ToggleDisplays(output) => self.open_display_modal(Some(&output)), control::ControlCommand::MonitorsChanged => self.load_display_inventory(), control::ControlCommand::ToggleGuide(output) => self.toggle_guide(output), control::ControlCommand::CancelLogout(serial) => self.cancel_logout_modal(serial), control::ControlCommand::Logout(serial, output) => { self.open_system_modal(system_modal::PowerAction::Logout(serial), Some(&output)) } }; reload_task = Task::batch([reload_task, task]); } } reload_task } Message::ActivateWorkspace(id) => { if let Some(control) = &self.control { control.activate_workspace(id); } self.dismiss_workspace_tooltip() } Message::HoverWorkspace(bar, workspace, target, entered) => { self.hover_workspace(bar, workspace, target, entered) } Message::WorkspaceTooltipDelay(serial) => self.locate_workspace_tooltip(serial), Message::WorkspaceTooltipBounds(serial, bounds) => self.show_workspace_tooltip(serial, bounds), Message::WorkspaceIconsLoaded(result) => self.workspace_icons_loaded(result), Message::ToggleOverview => { self.overview_active = !self.overview_active; if let Some(control) = &self.control { control.set_overview_active(self.overview_active); } self.dismiss_workspace_tooltip() } } } fn style(&self) -> Option { // Shell surfaces own their backgrounds. Never inherit an opaque // application clear color during startup or a system-theme change. Some(shell_surface_style(self.config.theme)) } fn view(&self) -> Element<'_, Self::Message> { text("").into() } fn view_window(&self, _id: window::Id) -> Element<'_, Self::Message> { text("").into() } } impl FereseShell { fn reload_config(&mut self, source: String) -> Task { let config = match config::parse_source(&source) { Ok(config) => config, Err(error) => { eprintln!("ferese-shell: reload rejected; keeping current config: {error}"); self.notifications .service_error("Configuration could not be loaded", &error.to_string()); return Task::none(); } }; self.apply_config(config) } fn apply_config(&mut self, mut config: config::ShellConfig) -> Task { if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() { config.wallpaper.path = None; } if self.config.font_family != config.font_family { *SHELL_FONT .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) .write() .unwrap() = configured_font(config.font_family.as_deref()); } motion::configure(config.animations, config.theme.material_radius); if self.config.animations != config.animations { self.notifications.update_motion_settings(config.animations); if let Some(menu) = &mut self.menu { menu.motion.update_settings(config.animations); } if let Some(modal) = &mut self.system_modal { modal.motion.update_settings(config.animations); } } self.status_service .update_settings(config.status.settings_command.clone()); let composition_changed = !self.config.panels[0].same_composition(&config.panels[0]); let edge_changed = self.config.panels[0].edge != config.panels[0].edge; let old = self.config.theme; let old_geometry = self.config.panels[0].geometry; let theme = config.theme; let geometry = config.panels[0].geometry; let geometry_changed = edge_changed || old_geometry != geometry; self.notifications.configure(config.notifications.clone()); self.config = config; let workspace_task = self.update_workspace_ui(); let mut tasks = vec![workspace_task]; if composition_changed || geometry_changed { tasks.push(self.destroy_menu()); for output in &mut self.outputs { // Keep the measured allocation and margins until the next layout // publishes its replacement through PanelResolved. tasks.push(set_input_zone( output.bar, bar::input_region(self.config.panels[0].surface, output.hidden, &output.bar_regions), )); } } if !self.guide_load.manual && !self.config.status.keybinding_guide && self.system_modal.as_ref().is_some_and(|modal| modal.is_guide()) { tasks.push(self.destroy_system_modal(false)); } if old.palette() != theme.palette() { tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); } for entry in self.outputs.iter().filter(|_| geometry_changed) { let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT)); tasks.push(set_size(entry.bar, None, Some(geometry.height.round() as u32))); tasks.push(set_exclusive_zone( entry.bar, (geometry.height.round() as i32).saturating_add(geometry.window_clearance), )); } let bars: Vec<_> = self.outputs.iter().map(|entry| entry.bar).collect(); for bar in bars { tasks.push(self.update_panel_margin(bar, edge_changed || old_geometry.edge_margin != geometry.edge_margin)); } if geometry_changed { tasks.push(self.dismiss_workspace_tooltip()); if let Some(surface) = self.notification_surface.take() { tasks.push(destroy_layer_surface(surface.id)); } tasks.push(self.sync_notification_surface()); } Task::batch(tasks) } fn update_panel_margin(&mut self, bar: window::Id, force: bool) -> Task { let geometry = self.config.panels[0].geometry; let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == bar) else { return Task::none(); }; let margin = entry.size.zip(entry.panel_resolution.as_ref()).map_or( entry.bar_margin_horizontal, |((width, _), resolution)| { resolution.side_margin( geometry.side_margins, width, f32::from(geometry.inner_padding.round() as u16), ) }, ); if entry.bar_margin_horizontal == margin && !force { return Task::none(); } entry.bar_margin_horizontal = margin; let (_, margin) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, margin); set_margin(bar, margin.top, margin.right, margin.bottom, margin.left) } } fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style { theme.palette().application_style(Color::TRANSPARENT) } fn color([red, green, blue, alpha]: [u8; 4]) -> Color { Color::from_rgba8(red, green, blue, f32::from(alpha) / 255.0) } fn color_with_opacity(mut value: [u8; 4], opacity: f32) -> Color { value[3] = (f32::from(value[3]) * opacity).round() as u8; color(value) } #[cfg(test)] mod tests { use super::*; use crate::control::{OutputSnapshot, WindowSnapshot}; pub(super) fn shell_with_measured_panel() -> FereseShell { let config = ShellConfig::default(); // This output is never dispatched; tests use only its stored geometry. let (socket, _peer) = std::os::unix::net::UnixStream::pair().unwrap(); let connection = Connection::from_socket(socket).unwrap(); let output = wl_output::WlOutput::inert(connection.backend().downgrade()); FereseShell { core: Core::default(), notifications: notifications::Center::new(config.notifications.clone()), notification_surface: None, status_service: status::Service::start(None), status: Default::default(), recorder: Default::default(), media: Default::default(), calendar_offset: 0, status_error: None, theme_error: None, menu: None, system_modal: None, display_mode: Default::default(), guide_shown: false, guide_load: Default::default(), guide_attempts: 0, pending_power: None, config, wallpaper: None, control: None, snapshot: Default::default(), workspace_ui: Default::default(), overview_active: false, clock: String::new(), outputs: vec![OutputSurfaces { output, name: Some("test".into()), bar: window::Id::unique(), wallpaper: None, size: Some((1200, 800)), effects: None, bar_regions: vec![[0., 0., 600., 36., 8.]], panel_resolution: Some(panel_layout::Resolution { minimum_width: 600., ..Default::default() }), bar_margin_horizontal: 0, hidden: false, }], } } #[test] fn panel_placement_uses_only_the_selected_edge_for_anchor_and_margin() { use ferese_config::panel::Edge; for gap in [0, 12] { let (anchor, margin) = surfaces::panel_placement(Edge::Top, gap, 32); assert_eq!(anchor, Anchor::TOP); assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (gap, 32, 0, 32)); let (anchor, margin) = surfaces::panel_placement(Edge::Bottom, gap, 32); assert_eq!(anchor, Anchor::BOTTOM); assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 32, gap, 32)); } } #[test] fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() { let mut shell = shell_with_measured_panel(); shell.config.panels[0].geometry.side_margins = 240; drop(shell.update_panel_margin(shell.outputs[0].bar, false)); assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); let popup = window::Id::unique(); shell.menu = Some(status_ui::OpenMenu { id: popup, anchor: status_ui::PopoverAnchor { parent: shell.outputs[0].bar, panel: shell.config.panels[0].id.clone(), item: Some(panel::ItemId("clock".into())), rectangle: Default::default(), }, kind: status_ui::Menu::Calendar, motion: motion::PopupMotion::new(Default::default()), effects: None, regions: Default::default(), }); let resolution = shell.outputs[0].panel_resolution.clone(); for (field, value) in [ ("background_opacity", serde_json::json!(0.5)), ("corner_radius", serde_json::json!("4px 8px")), ("border", serde_json::json!(true)), ("group_surface", serde_json::json!("inset")), ] { let edit = field; let mut document = ferese_config::Document::parse("").unwrap(); document .set("panels", serde_json::to_value(&shell.config.panels).unwrap()) .unwrap(); document.set(&format!("panels.0.{field}"), value).unwrap(); let source = document.to_string(); let mut config = config::parse_source(&source).unwrap(); config.theme = shell.config.theme; drop(shell.apply_config(config)); assert_eq!(shell.outputs[0].panel_resolution, resolution, "{edit}"); assert_eq!(shell.outputs[0].bar_margin_horizontal, 240, "{edit}"); assert_eq!(shell.menu.as_ref().unwrap().id, popup, "{edit}"); } // A real membership edit closes the popup, but retains the last allocation // until the adaptive widget measures and publishes its replacement. let mut config = shell.config.clone(); config.panels[0].end.groups.clear(); drop(shell.apply_config(config)); assert!(shell.menu.is_none()); assert_eq!(shell.outputs[0].panel_resolution, resolution); assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); use cosmic::Application; let replacement = panel_layout::Resolution { minimum_width: 900., ..Default::default() }; drop(shell.update(Message::PanelResolved(shell.outputs[0].bar, replacement.clone()))); assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement)); assert!(shell.outputs[0].bar_margin_horizontal < 240); let before = shell.outputs[0].bar_margin_horizontal; drop(shell.reload_config("panel broken {".into())); assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(&replacement)); assert_eq!(shell.outputs[0].bar_margin_horizontal, before); } #[test] fn panel_reallocation_dismisses_popovers_in_both_overflow_directions() { use cosmic::Application; use panel_layout::{Placement, Resolution}; let item = panel::ItemId("network".into()); let visible = Resolution { items: [( item.clone(), Placement::Visible { representation: panel::Representation::Icon, width: 28., }, )] .into(), zone_widths: [0., 0., 40.], ..Default::default() }; let overflow = Resolution { items: [(item.clone(), Placement::Overflow)].into(), overflow: vec![item.clone()], zone_widths: [0., 0., 32.], overflow_trigger_width: 24., ..Default::default() }; let mut moved = visible.clone(); moved.zone_widths[2] = 160.; for (before, after, kind) in [ (&overflow, &visible, status_ui::Menu::Network), (&visible, &overflow, status_ui::Menu::Network), (&visible, &moved, status_ui::Menu::Network), (&overflow, &visible, status_ui::Menu::Overflow), ] { let mut shell = shell_with_measured_panel(); let bar = shell.outputs[0].bar; shell.outputs[0].panel_resolution = Some(before.clone()); shell.menu = Some(status_ui::OpenMenu { id: window::Id::unique(), anchor: status_ui::PopoverAnchor { parent: bar, panel: shell.config.panels[0].id.clone(), item: Some(if kind == status_ui::Menu::Overflow { panel::ItemId("_overflow".into()) } else { item.clone() }), rectangle: cosmic::iced::Rectangle { x: 1100, y: 0, width: 28, height: 36, }, }, kind, motion: motion::PopupMotion::new(Default::default()), effects: None, regions: Default::default(), }); let popup = shell.menu.as_ref().unwrap().id; drop(shell.update(Message::PanelResolved(window::Id::unique(), after.clone()))); assert_eq!( shell.menu.as_ref().unwrap().id, popup, "another output must not dismiss this popup" ); drop(shell.update(Message::PanelResolved(bar, before.clone()))); assert_eq!( shell.menu.as_ref().unwrap().id, popup, "unchanged allocation must preserve the popup" ); drop(shell.update(Message::PanelResolved(bar, after.clone()))); assert!(shell.menu.is_none(), "stale {kind:?} popup must be destroyed"); assert_eq!(shell.outputs[0].panel_resolution.as_ref(), Some(after)); } } #[test] fn late_open_checks_preserve_owned_wallpaper_and_tooltips() { let mut shell = shell_with_measured_panel(); let wallpaper = window::Id::unique(); let tooltip = window::Id::unique(); shell.outputs[0].wallpaper = Some(wallpaper); shell.workspace_ui.tooltip = Some(tooltip); for id in [wallpaper, tooltip] { assert!(shell.owns_surface(id)); assert_eq!( shell .handle_event( Event::Window(window::Event::Opened { position: None, size: cosmic::iced::Size::new(100., 100.), }), id, ) .units(), 0, "an owned surface must not be destroyed on opening" ); } assert!(!shell.owns_surface(window::Id::unique())); } #[test] fn an_empty_measured_panel_is_not_an_unmeasured_panel() { let mut shell = shell_with_measured_panel(); let bar = shell.outputs[0].bar; shell.config.panels[0].geometry.side_margins = 240; shell.outputs[0].panel_resolution = None; drop(shell.update_panel_margin(bar, false)); assert_eq!(shell.outputs[0].bar_margin_horizontal, 0); shell.outputs[0].panel_resolution = Some(Default::default()); drop(shell.update_panel_margin(bar, false)); assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); } #[test] fn shell_font_respects_configured_family() { assert_eq!( configured_font(Some("JetBrainsMono Nerd Font")), cosmic::font::Font::with_name("JetBrainsMono Nerd Font") ); assert_eq!(configured_font(None), cosmic::font::default()); } #[test] fn bar_decoration_does_not_follow_transitional_text_contrast() { let theme = config::ShellTheme::default(); let contrasted = config::ShellTheme { text_primary: [0, 0, 0, 255], text_muted: [255, 255, 255, 255], ..theme }; let before = bar_group_style(theme); let after = bar_group_style(contrasted); assert_eq!(before.border, after.border); assert_eq!(before.background, after.background); assert_eq!(before.border.color, color(theme.border)); } #[test] fn workspace_highlight_tracks_the_bars_output() { let output = control::OutputSnapshot { id: 1, name: "eDP-1".into(), active_workspace: 2, focused: false, }; assert!(workspace_active_on_bar(2, Some(&output))); assert!(!workspace_active_on_bar(1, Some(&output))); assert!(!workspace_active_on_bar(2, None)); } #[test] fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() { let theme = ShellTheme::default(); assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT); let Some(Background::Color(background)) = bar_style(theme.for_bar(), false, false).background else { panic!("first frame needs a fallback fill before material attachment"); }; assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20); assert!(background.a > 0.9); assert!(theme.bar_text_primary[..3].iter().all(|channel| *channel > 220)); } #[test] fn bar_fallback_uses_the_matching_dark_palette_and_geometry() { let theme = ShellTheme::default().for_bar(); let style = bar_style(theme, false, false); assert_eq!(style.background, Some(Background::Color(color(theme.bar_background)))); assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); assert_eq!(style.border.radius, theme.bar_radius.into()); assert_eq!(style.shadow, cosmic::iced::Shadow::default()); } #[test] fn bar_does_not_cover_the_selected_compositor_material() { let theme = ShellTheme::default().for_bar(); let style = bar_style(theme, true, false); assert_eq!(style.background, None); assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); assert_eq!(style.border.radius, theme.bar_radius.into()); assert_eq!(style.shadow, cosmic::iced::Shadow::default()); } #[test] fn compact_bar_preserves_logical_text_and_icon_sizes() { for height in [24.0, 26.0, 32.0, 38.0, 44.0] { let metrics = BarMetrics::from(panel::PanelGeometry { height, ..Default::default() }); assert_eq!(metrics.text_size, 14); assert_eq!(metrics.icon_size, 20); assert_eq!(metrics.overview_icon_size, 20); assert!(metrics.control_height >= f32::from(metrics.icon_size)); assert!(metrics.control_height <= height); } } #[test] fn fullscreen_on_the_active_workspace_hides_the_bar() { let snapshot = snapshot_with_fullscreen_window(7); assert!(bar_hidden(&snapshot)); } #[test] fn fullscreen_on_an_inactive_workspace_keeps_the_bar_visible() { let snapshot = snapshot_with_fullscreen_window(8); assert!(!bar_hidden(&snapshot)); } #[test] fn fullscreen_visibility_is_local_to_each_monitor() { let mut snapshot = snapshot_with_fullscreen_window(7); snapshot.outputs.push(OutputSnapshot { id: 2, name: "external-test".to_owned(), active_workspace: 9, focused: false, }); assert!(output_bar_hidden(&snapshot, Some(1))); assert!(!output_bar_hidden(&snapshot, Some(2))); assert!(!output_bar_hidden(&snapshot, None)); snapshot.outputs[0].focused = false; snapshot.outputs[1].focused = true; assert!(output_bar_hidden(&snapshot, Some(1))); assert!(!output_bar_hidden(&snapshot, Some(2))); } #[test] fn bar_title_tracks_focus_and_the_outputs_active_workspace() { let mut snapshot = snapshot_with_fullscreen_window(7); assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), "Test"); snapshot.windows[0].focused = false; assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), ""); snapshot.windows[0].focused = true; snapshot.windows[0].workspace = 8; assert_eq!(focused_bar_title(&snapshot, snapshot.outputs.first()), ""); snapshot.windows[0].workspace = 7; snapshot.windows[0].title.clear(); assert_eq!( focused_bar_title(&snapshot, snapshot.outputs.first()), "dev.ferese.Test" ); assert_eq!(focused_bar_title(&snapshot, None), ""); } fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot { ShellSnapshot { outputs: vec![OutputSnapshot { id: 1, name: "eDP-1".to_owned(), active_workspace: 7, focused: true, }], workspaces: Vec::new(), windows: vec![WindowSnapshot { id: 42, workspace, app_id: "dev.ferese.Test".to_owned(), title: "Test".to_owned(), focused: true, urgent: false, fullscreen: true, floating: false, }], } } }