mod bar; mod desktop_widgets; mod surfaces; use bar::*; use desktop_widgets::*; use surfaces::*; mod compositor_ipc; mod config; mod control; mod keybinding_guide; mod motion; mod note_store; mod notification_ui; mod notifications; mod recording; mod status; mod status_ui; mod system_modal; 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::calendar::{format_bar_time, stacked_digits as stacked_clock_digits}; use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon}; use jiff::Zoned; 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, } impl From for BarMetrics { fn from(theme: ShellTheme) -> Self { let height = theme.bar_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: (height - 4.0).max(21.0).min(height), } } } fn main() -> cosmic::iced::Result { // Small shell surfaces do not need a second GPU device and shader setup. // Keep wgpu as a fallback and honor an explicit renderer preference. if std::env::var_os("ICED_BACKEND").is_none() { // SAFETY: this is the process entry point, before any worker or // toolkit threads are started. unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") }; } cosmic::iced::advanced::graphics::text::font_system() .write() .unwrap() .load_font(std::borrow::Cow::Borrowed(include_bytes!( "../../../assets/fonts/Comfortaa-Regular.otf" ))); cosmic::iced::advanced::graphics::text::font_system() .write() .unwrap() .load_font(std::borrow::Cow::Borrowed(include_bytes!( "../../../assets/fonts/Cantarell-ExtraBold.otf" ))); let mut config = config::load(); 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, bar_surface_id: window::Id, config: ShellConfig, wallpaper: Option, control: Option, snapshot: ShellSnapshot, overview_active: bool, clock: String, desktop_clock: (String, String), outputs: Vec, notifications: notifications::Center, notification_surface: Option, status_service: status::Service, status: status::Snapshot, recorder: recording::Recorder, calendar_offset: i32, status_error: Option, theme_error: Option, menu: Option, system_modal: Option, guide_shown: bool, guide_load: keybinding_guide::LoadState, guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, note_editor: Option, note_drag: Option, note_pointer: std::collections::HashMap, note_pending: Vec, note_inflight: Vec, note_saving: bool, note_error: Option, } struct DesktopNoteEditor { id: String, content: cosmic::widget::text_editor::Content, revision: u64, } struct NoteDrag { id: Option, source: window::Id, overlay: window::Id, start: cosmic::iced::Point, origin: cosmic::iced::Point, position: cosmic::iced::Point, output_size: (i32, i32), obstacles: Vec, } struct OutputSurfaces { output: wl_output::WlOutput, name: Option, bar: window::Id, wallpaper: Option, clock: Option, notes: Vec<(String, window::Id)>, size: Option<(i32, i32)>, effects: Option, hidden: bool, } #[derive(Clone, Debug)] enum Message { ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), ThemeModeSet(Result<(), String>), BeginNoteEdit(String), NoteAction(String, cosmic::widget::text_editor::Action), SaveNote(String, u64), FinishNoteEdit, BeginNoteDrag(String, window::Id), BeginClockDrag(window::Id), NotesStored(Result<(), String>), WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>), Tick, ActivateWorkspace(u64), 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, OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), Control(status::Action), ShowGuide, GuideLoaded { generation: u64, manual: bool, output: Option, result: Result, String>, }, 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 bar_surface_id = window::Id::unique(); let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default(); let app = Self { core, bar_surface_id, 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(), calendar_offset: 0, status_error: None, theme_error: None, menu: None, system_modal: None, guide_shown: false, guide_load: Default::default(), guide_attempts: 0, pending_power: None, note_editor: None, note_drag: None, note_pointer: Default::default(), note_pending: Vec::new(), note_inflight: Vec::new(), note_saving: false, note_error: None, config, wallpaper: None, control: ShellControl::connect() .map_err(|error| { eprintln!("ferese-shell: shell control unavailable: {error}"); }) .ok(), snapshot: ShellSnapshot::default(), overview_active: false, clock: current_time(), desktop_clock, 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([ ferese_theme::service::subscription().map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), if self .system_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), if self.notifications.has_toasts() { cosmic::iced::time::every(Duration::from_millis(if self.notifications.animating() { 16 } else { 250 })) .map(|_| Message::NotificationTick) } else { Subscription::none() }, event::listen_with(|event, _status, id| match &event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING .load(Ordering::Relaxed) .then_some(Message::Event(event, id)), Event::Keyboard(_) | 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, }), cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick), self.status_service.subscription().map(Message::StatusUpdated), if self.config.desktop_widgets.clock.enabled || self .config .desktop_widgets .notes .iter() .any(|note| note.enabled && note.interactive) { event::listen_with(|event, _, id| { matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id)) }) } else { Subscription::none() }, if self .menu .as_ref() .is_some_and(|menu| menu.animating() || menu.motion.closing()) { cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu) } else { Subscription::none() }, ]) } fn update(&mut self, message: Self::Message) -> Task { if let Some(menu) = &self.menu && let Some(effects) = &menu.effects { if let Err(error) = effects.set_opacity(menu.progress()) { eprintln!("ferese-shell: could not update popup opacity: {error}"); } let regions = menu.regions.lock().unwrap().clone(); if let Err(error) = effects.set_regions(®ions) { eprintln!("ferese-shell: could not update card materials: {error}"); } } if let Some(surface) = &self.notification_surface && let Some(effects) = &surface.effects { let regions = surface.regions.lock().unwrap().clone(); if let Err(error) = effects.set_regions(®ions) { eprintln!("ferese-shell: could not update notification materials: {error}"); } } self.update_power_materials(); match message { 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::BeginNoteEdit(id) => { let mut tasks = vec![self.finish_note_edit()]; if let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) { self.note_editor = Some(DesktopNoteEditor { id, content: cosmic::widget::text_editor::Content::with_text(¬e.text), revision: 0, }); } tasks.push(Task::none()); Task::batch(tasks) } Message::FinishNoteEdit => self.finish_note_edit(), Message::NoteAction(id, action) => { if let Some(editor) = &mut self.note_editor && editor.id == id { let edited = action.is_edit(); editor.content.perform(action); if edited { editor.revision = editor.revision.wrapping_add(1); let revision = editor.revision; return cosmic::task::future(async move { // iced executor is Tokio; do not spawn blocking sleep threads. tokio::time::sleep(Duration::from_millis(500)).await; Message::SaveNote(id, revision) }); } } Task::none() } Message::SaveNote(id, revision) => { if let Some(editor) = &self.note_editor && editor.id == id && editor.revision == revision { let value = editor.content.text(); self.set_note_text(&id, value) } else { Task::none() } } Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface), Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface), Message::NotesStored(result) => { self.note_saving = false; match result { Ok(()) => { self.note_inflight.clear(); self.note_error = None; self.flush_note_changes() } Err(error) => { self.note_pending.splice(0..0, self.note_inflight.drain(..)); self.note_error = Some(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(|entry| entry.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::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), Message::OpenMenuOn(id, kind, anchor) => { if self.bar_surface_id != id { let destroy = self.destroy_menu(); self.bar_surface_id = id; let open = self.open_menu(kind, anchor); Task::batch([destroy, open]) } else { 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::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::Tick => { self.recorder.poll(); self.clock = current_time(); if self.config.desktop_widgets.clock.enabled { self.desktop_clock = self .config .desktop_widgets .clock .labels(&Zoned::now()) .unwrap_or_default(); } 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 let Some(source) = poll.config { reload_task = self.reload_config(source); } if let Some(snapshot) = poll.snapshot { self.snapshot = snapshot; let mut tasks = vec![reload_task]; 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; 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, if hidden { Some(Vec::new()) } else { None })); } if self.bar_hidden(self.bar_surface_id) { tasks.push(self.destroy_menu()); } reload_task = Task::batch(tasks); } for output in poll.guide_toggles { reload_task = Task::batch([reload_task, self.toggle_guide(output)]); } 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_system_modal(system_modal::PowerAction::Logout(serial), Some(&output)), ]); } } reload_task } Message::ActivateWorkspace(id) => { if let Some(control) = &self.control { control.activate_workspace(id); } Task::none() } Message::ToggleOverview => { self.overview_active = !self.overview_active; if let Some(control) = &self.control { control.set_overview_active(self.overview_active); } Task::none() } } } 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}"); 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 { 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 old = self.config.theme; let old_clock = &self.config.desktop_widgets.clock; let old_notes = &self.config.desktop_widgets.notes; let new_notes = &config.desktop_widgets.notes; let notes_changed = old_notes.len() != new_notes.len() || old_notes.iter().zip(new_notes).any(|(old, new)| !old.same_surface(new)); let new_clock = &config.desktop_widgets.clock; let clock_changed = old_clock.enabled != new_clock.enabled || old_clock.outputs != new_clock.outputs || old_clock.anchor != new_clock.anchor || old_clock.width != new_clock.width || old_clock.height != new_clock.height || old_clock.margin_x != new_clock.margin_x || old_clock.margin_y != new_clock.margin_y; let theme = config.theme; let geometry_changed = old.bar_height != theme.bar_height || old.bar_margin_top != theme.bar_margin_top || old.bar_margin_horizontal != theme.bar_margin_horizontal || old.bar_window_gap != theme.bar_window_gap; self.notifications.configure(config.notifications.clone()); self.config = config; self.desktop_clock = self .config .desktop_widgets .clock .labels(&Zoned::now()) .unwrap_or_default(); let mut tasks = vec![if clock_changed { self.rebuild_clocks(true) } else { Task::none() }]; 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())); } if clock_changed && self.note_drag.as_ref().is_some_and(|drag| drag.id.is_none()) { tasks.push(self.finish_note_drag(false)); } if notes_changed { tasks.push(self.finish_note_drag(false)); if self.note_editor.as_ref().is_some_and(|editor| { !self .config .desktop_widgets .notes .iter() .any(|note| note.id == editor.id && note.interactive) }) { self.note_editor = None; } tasks.push(self.rebuild_notes(true)); } for entry in self.outputs.iter().filter(|_| geometry_changed) { tasks.push(set_size(entry.bar, None, Some(theme.bar_height.round() as u32))); tasks.push(set_margin( entry.bar, theme.bar_margin_top, theme.bar_margin_horizontal, 0, theme.bar_margin_horizontal, )); tasks.push(set_exclusive_zone( entry.bar, (theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap), )); } Task::batch(tasks) } } 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}; #[test] fn widget_drag_stops_at_edges_without_tunnelling_and_slides() { use cosmic::iced::{Point, Rectangle}; let obstacles = [Rectangle { x: 100., y: 0., width: 40., height: 100., }]; assert_eq!( super::avoid_widget_overlap(Point::new(0., 0.), Point::new(300., 0.), (20, 20), &obstacles), Point::new(80., 0.) ); assert_eq!( super::avoid_widget_overlap(Point::new(80., 0.), Point::new(120., 50.), (20, 20), &obstacles), Point::new(80., 50.) ); assert_eq!( super::avoid_widget_overlap(Point::new(160., 0.), Point::new(0., 0.), (20, 20), &obstacles), Point::new(140., 0.) ); assert_eq!( super::avoid_widget_overlap(Point::new(100., 120.), Point::new(100., 0.), (20, 20), &obstacles), Point::new(100., 100.) ); assert_eq!( super::avoid_widget_overlap(Point::new(0., 100.), Point::new(200., 100.), (20, 20), &obstacles), Point::new(200., 100.) ); } #[test] fn note_drag_origin_and_bounds_use_logical_output_coordinates() { use cosmic::iced::Point; let mut note = ferese_core::desktop::StickyNote::default(); assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(1552., 80.)); note.anchor = ferese_core::desktop::Anchor::Center; assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(800., 420.)); assert_eq!( super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)), Point::new(0., 840.) ); assert_eq!( super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)), Point::ORIGIN ); } #[test] fn desktop_clock_anchor_margins_match_only_anchored_edges() { use ferese_core::desktop::{Anchor as Position, Clock}; let clock = Clock { anchor: Position::BottomRight, margin_x: 30, margin_y: 40, ..Clock::default() }; let (anchor, margin) = super::clock_placement(&clock); assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT); assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 30, 40, 0)); let (anchor, margin) = super::clock_placement(&Clock { anchor: Position::Center, ..clock }); assert!(anchor.is_empty()); assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 0, 0, 0)); } #[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 workspace_selector_has_distinct_active_paint() { let theme = ShellTheme::default(); let active = workspace_selector_style(true, false, false, theme); let occupied = workspace_selector_style(false, false, true, theme); let inactive = workspace_selector_style(false, false, false, theme); let remote = workspace_selector_style(false, true, false, theme); assert!(active.background.is_some()); assert_eq!( active.background, Some(Background::Color(color_with_opacity(theme.accent, 0.16))) ); assert!(occupied.background.is_some()); assert!(inactive.background.is_none()); assert_eq!(inactive.border.width, 0.0); assert_eq!(remote.border.width, 1.0); assert!(remote.background.is_none()); } #[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).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); 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); 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(ShellTheme { bar_height: height, ..ShellTheme::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 { workspace, app_id: "dev.ferese.Test".to_owned(), title: "Test".to_owned(), focused: true, fullscreen: true, }], } } }