mod config; mod control; mod motion; mod status; mod status_ui; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; // 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); use cosmic::Element; use cosmic::app::{Core, Settings, Task}; use cosmic::iced::alignment; use cosmic::iced::event::{self, PlatformSpecific, wayland}; use cosmic::iced::platform_specific::{ runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, shell::commands::layer_surface::{ destroy_layer_surface, set_exclusive_zone, set_input_zone, set_margin, set_size, }, }; use cosmic::iced::{ Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window, }; use cosmic::theme; use cosmic::widget::{button, container, icon, image, row}; use ferese_protocols::effects::v1::client::{ ferese_effects_manager_v1::FereseEffectsManagerV1, ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, }; use jiff::Zoned; use wayland_client::{ Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop, globals::{GlobalListContents, registry_queue_init}, protocol::{wl_output, wl_registry, wl_surface}, }; use crate::config::{ShellConfig, ShellTheme, WallpaperMode}; use crate::control::{ShellControl, ShellSnapshot}; const APP_ID: &str = "dev.ferese.Shell"; static SHELL_FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); fn configured_font(family: Option<&str>) -> cosmic::font::Font { family.map_or_else(cosmic::font::default, |family| { // Font names require static storage. Intern repeated families and cap // the cache so live theme edits cannot leak unlimited strings. static FONTS: std::sync::OnceLock< std::sync::Mutex>, > = std::sync::OnceLock::new(); let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap(); if let Some(font) = fonts.get(family) { return *font; } if fonts.len() >= 64 { return cosmic::font::default(); } let family = Box::leak(family.to_owned().into_boxed_str()); let font = cosmic::font::Font::with_name(family); fonts.insert(family.to_owned(), font); font }) } // COSMIC's text helper explicitly selects its interface font, overriding // Settings::default_font. Use this helper for every bar and menu label. fn text<'a>( content: impl Into> + 'a, ) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { cosmic::widget::text(content).font( *SHELL_FONT .get_or_init(|| std::sync::RwLock::new(cosmic::font::default())) .read() .unwrap(), ) } #[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: 13, icon_size: 19, overview_icon_size: 19, 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") }; } let mut config = config::load(); motion::configure(config.animations); 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); 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, outputs: Vec, status_service: status::Service, status: status::Snapshot, status_error: Option, menu: Option, } struct OutputSurfaces { output: wl_output::WlOutput, name: Option, bar: window::Id, wallpaper: Option, effects: Option, hidden: bool, } #[derive(Clone, Debug)] enum Message { WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface( window::Id, Result<(Connection, wl_surface::WlSurface), String>, ), Tick, ActivateWorkspace(u64), ToggleOverview, StatusTick, AnimateMenu, OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), Control(status::Action), ConfirmPower(status::Action), CancelPower, } 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 app = Self { core, bar_surface_id, status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), status_error: None, menu: 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(), 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([ 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), cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick), 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}"); } } match message { 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)) => { if let Some(menu) = &mut self.menu && menu.id == id && menu.effects.is_none() { match EffectsBinding::attach_role(&surface, menu.kind.material_role()) { Ok(binding) => menu.effects = Some(binding), Err(error) => { eprintln!("ferese-shell: popover material unavailable: {error}") } } if let Some(effects) = &menu.effects { let _ = effects.set_opacity(menu.progress()); } } } Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"), } Task::none() } Message::StatusTick => { if let Some(update) = self.status_service.poll() && update.generation >= self.status_service.generation { self.status = update.snapshot; self.status_error = update.error; } Task::none() } 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::ConfirmPower(action) => { if let Some(menu) = &mut self.menu { menu.confirm = Some(action); } Task::none() } Message::CancelPower => { if let Some(menu) = &mut self.menu { menu.confirm = None; } Task::none() } Message::Control(action) => { 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.clock = current_time(); 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 { if 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()); } return Task::batch(tasks); } } 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 mut config = match config::parse_source(&source) { Ok(config) => config, Err(error) => { eprintln!("ferese-shell: reload rejected; keeping current config: {error}"); return Task::none(); } }; 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); if self.config.animations != config.animations && let Some(menu) = &mut self.menu { menu.motion.update_settings(config.animations); } self.status_service .update_settings(config.status.settings_command.clone()); let old = self.config.theme; 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.config = config; if !geometry_changed { return Task::none(); } let mut tasks = Vec::new(); for entry in &self.outputs { 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 output_event( &mut self, event: wayland::OutputEvent, output: wl_output::WlOutput, ) -> Task { if matches!(event, wayland::OutputEvent::Removed) { if let Some(index) = self.outputs.iter().position(|entry| entry.output == output) { let entry = self.outputs.remove(index); let menu = if self.bar_surface_id == entry.bar { self.destroy_menu() } else { Task::none() }; let mut tasks = vec![destroy_layer_surface(entry.bar), menu]; if let Some(wallpaper) = entry.wallpaper { tasks.push(destroy_layer_surface(wallpaper)); } return Task::batch(tasks); } return Task::none(); } let name = match event { wayland::OutputEvent::Created(info) => info.and_then(|info| info.name), wayland::OutputEvent::InfoUpdate(info) => info.name, _ => None, }; if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { if name.is_some() { entry.name = name; } return Task::none(); } let bar_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique(); let shell_theme = self.config.theme; let bar = BarMetrics::from(shell_theme); let wallpaper_output = output.clone(); let bar_output = output.clone(); let hidden = output_bar_hidden( &self.snapshot, self.snapshot .outputs .iter() .find(|output| Some(output.name.as_str()) == name.as_deref()) .map(|output| output.id), ); self.outputs.push(OutputSurfaces { output, name, bar: bar_surface_id, wallpaper: std::env::var_os("FERESE_COMPOSITOR_WALLPAPER") .is_none() .then_some(wallpaper_surface_id), effects: None, hidden, }); let wallpaper_action = cosmic::surface::action::app_layer_shell::( |_| Default::default(), move |_| SctkLayerSurfaceSettings { id: wallpaper_surface_id, layer: Layer::Background, keyboard_interactivity: KeyboardInteractivity::None, input_zone: Some(Vec::new()), anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, output: IcedOutput::Output(wallpaper_output.clone()), namespace: "ferese-shell-wallpaper".to_owned(), // Wallpaper covers the full output, including beneath the bar // and its floating margins. Zero would use the remaining workspace. exclusive_zone: -1, size: Some((None, None)), size_limits: Limits::NONE, ..Default::default() }, Some(Box::new(Self::view_wallpaper)), ); let bar_action = cosmic::surface::action::app_layer_shell::( |_| Default::default(), move |_| SctkLayerSurfaceSettings { id: bar_surface_id, input_zone: hidden.then(Vec::new), layer: Layer::Top, keyboard_interactivity: KeyboardInteractivity::None, anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, output: IcedOutput::Output(bar_output.clone()), namespace: "ferese-shell-top-bar".to_owned(), margin: IcedMargin { top: shell_theme.bar_margin_top, right: shell_theme.bar_margin_horizontal, bottom: 0, left: shell_theme.bar_margin_horizontal, }, size: Some((None, Some(bar.height.round() as u32))), size_limits: Limits::NONE, // Reserve breathing room below the visible bar; layer-shell // accounts for the top margin separately. exclusive_zone: (bar.height.round() as i32) .saturating_add(shell_theme.bar_window_gap), ..Default::default() }, Some(Box::new(move |app| app.view_layer(bar_surface_id))), ); // Submit the small interactive surface before the full-screen image. let mut surfaces = vec![bar_action]; if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_none() { surfaces.push(wallpaper_action); } let tasks = surfaces .into_iter() .map(cosmic::Action::Surface) .map(cosmic::task::message); Task::batch(tasks) } fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { let name = self .outputs .iter() .find(|entry| entry.bar == id)? .name .as_deref()?; self.snapshot .outputs .iter() .find(|output| output.name == name) } fn bar_hidden(&self, id: window::Id) -> bool { output_bar_hidden( &self.snapshot, self.output_for_bar(id).map(|output| output.id), ) } fn handle_event(&mut self, event: Event, id: window::Id) -> Task { match event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( event, output, ))) => self.output_event(event, output), Event::Window(window::Event::Opened { .. }) if self.outputs.iter().any(|entry| entry.bar == id) || self.menu.as_ref().is_some_and(|menu| menu.id == id) => { // Iced emits the first xdg_popup configure as Window::Opened, // not Popup::Configured. This is the popup's ready signal. if let Some(menu) = &mut self.menu && menu.id == id { menu.motion.begin(Instant::now()); if let Some(effects) = &menu.effects { let _ = effects.set_opacity(menu.progress()); } } window::run(id, native_wayland_surface) .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))) } Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), .. }) if self.menu.is_some() => self.close_menu(), Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup( event, surface, popup_id, ))) => { if let Some(menu) = &mut self.menu && menu.id == popup_id { match event { wayland::PopupEvent::Done => { self.menu = None; EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); return Task::none(); } wayland::PopupEvent::Focused => { if menu.effects.is_none() { match EffectsBinding::attach_role( &surface, menu.kind.material_role(), ) { Ok(binding) => menu.effects = Some(binding), Err(error) => eprintln!( "ferese-shell: popover material unavailable: {error}" ), } EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } } wayland::PopupEvent::Configured { .. } => { menu.motion.begin(Instant::now()); if menu.effects.is_none() { match EffectsBinding::attach_role( &surface, menu.kind.material_role(), ) { Ok(binding) => menu.effects = Some(binding), Err(error) => eprintln!( "ferese-shell: popover material unavailable: {error}" ), } EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } } _ => {} } if let Some(effects) = &menu.effects { let _ = effects.set_opacity(menu.progress()); } } Task::none() } Event::Window(window::Event::Closed) if self.menu.as_ref().is_some_and(|menu| menu.id == id) => { self.menu = None; EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); Task::none() } Event::Window(window::Event::Closed) => Task::none(), Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( _, surface, frame_id, ))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => { if let Some(menu) = &mut self.menu { if menu.effects.is_none() { match EffectsBinding::attach_role(&surface, menu.kind.material_role()) { Ok(binding) => menu.effects = Some(binding), Err(error) => { eprintln!("ferese-shell: popover material unavailable: {error}") } } EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } } Task::none() } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( _, surface, frame_id, ))) if self.outputs.iter().any(|entry| entry.bar == frame_id) => { self.attach_effects(frame_id, &surface); Task::none() } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( _, surface, layer_id, ))) if self.outputs.iter().any(|entry| entry.bar == layer_id) => { self.attach_effects(layer_id, &surface); Task::none() } _ => Task::none(), } } fn attach_effects(&mut self, id: window::Id, surface: &wl_surface::WlSurface) { let hidden = self.bar_hidden(id); let Some(entry) = self.outputs.iter_mut().find(|entry| entry.bar == id) else { return; }; if entry.effects.is_some() { return; } EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); match EffectsBinding::attach(surface, !hidden) { Ok(binding) => entry.effects = Some(binding), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), } } fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { if self.bar_hidden(id) { return container(text("")) .width(Length::Fill) .height(Length::Fill) .into(); } let focused_output = self.output_for_bar(id); let focused_output_id = focused_output.map(|output| output.id); let shell_theme = self.config.theme.for_bar(); let bar = BarMetrics::from(shell_theme); let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) .spacing(3) .align_y(cosmic::iced::Alignment::Center); workspace_row = workspace_row.push(motion::button( button::custom(overview_control(bar, color(shell_theme.text_primary))) .height(bar.control_height) .padding([0, 7]) .on_press(cosmic::Action::App(Message::ToggleOverview)), color(shell_theme.text_primary), self.overview_active, 1.0, )); for workspace in self .snapshot .workspaces .iter() .filter(|workspace| workspace_on_bar(workspace.output, focused_output_id)) { let occupied = self .snapshot .windows .iter() .any(|window| window.workspace == workspace.id); let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme); let workspace_button = button::custom(indicator) .name(format!( "Workspace {}{}", workspace.name, if workspace.active { ", active" } else { "" } )) .height(bar.control_height) .padding(0) .on_press(cosmic::Action::App(Message::ActivateWorkspace( workspace.id, ))); let selector = motion::button( workspace_button, color(if workspace.active { shell_theme.accent } else { shell_theme.text_primary }), false, 1.0, ); // Iced tooltips are overlays inside this bar-height layer surface; // viewport clamping puts them back over the selector. Keep the // accessible button name, without an overlay stealing its target. workspace_row = workspace_row.push(selector); } let foreground = color(shell_theme.text_primary); let left = cosmic::iced::widget::scrollable(workspace_row) .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( cosmic::iced::widget::scrollable::Scrollbar::default() .width(0) .scroller_width(0), )) .width(Length::Fill) .height(bar.control_height); let clock = container( text(&self.clock) .size(bar.text_size) .wrapping(cosmic::iced::core::text::Wrapping::None) .class(theme::Text::Color(foreground)), ) .padding([0, 6]); let right = row![ self.view_status_bar().map(move |action| match action { cosmic::Action::App(Message::OpenMenu(kind, anchor)) => cosmic::Action::App(Message::OpenMenuOn(id, kind, anchor)), other => other, }), clock ] .spacing(6) .align_y(cosmic::iced::Alignment::Center); let content = row![ container(left).width(Length::Fill), container(right).width(Length::Shrink), ] .spacing(16) .align_y(cosmic::iced::Alignment::Center) .height(Length::Fill); let compositor_material = self .outputs .iter() .any(|entry| entry.bar == id && entry.effects.is_some()); container(content) .width(Length::Fill) .height(Length::Fill) .padding([0, shell_theme.panel_padding.round() as u16]) .class(theme::Container::custom(move |_| { bar_style(shell_theme, compositor_material) })) .into() } fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { let Some(handle) = self.wallpaper.as_ref() else { return container(text("")) .width(Length::Fill) .height(Length::Fill) .class(theme::Container::custom(wallpaper_fallback_style)) .into(); }; let content_fit = match self.config.wallpaper.mode { WallpaperMode::Fill => ContentFit::Cover, WallpaperMode::Fit => ContentFit::Contain, }; container( image(handle.clone()) .width(Length::Fill) .height(Length::Fill) .content_fit(content_fit), ) .width(Length::Fill) .height(Length::Fill) .class(theme::Container::custom(wallpaper_fallback_style)) .into() } } fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon { accented_icon(source, size, foreground, foreground) } fn accented_icon(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon { let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8"); // Battery canvases are wider; reserve that space instead of stretching // or clipping them into the square slot used by the other status icons. let aspect = if svg.contains("viewBox=\"0 0 32 24\"") { 4.0 / 3.0 } else { 1.0 }; icon::from_svg_bytes(tinted_svg(source, foreground, accent)) .symbolic(false) .icon() .size(size) .width(Length::Fixed(f32::from(size) * aspect)) .content_fit(ContentFit::Contain) .class(theme::Svg::custom(move |_| { cosmic::iced::widget::svg::Style { color: None } })) } fn tinted_svg(source: &[u8], foreground: Color, accent: Color) -> Vec { let rgba = |foreground: Color| { format!( "rgba({},{},{},{})", (foreground.r * 255.0).round() as u8, (foreground.g * 255.0).round() as u8, (foreground.b * 255.0).round() as u8, foreground.a, ) }; std::str::from_utf8(source) .expect("embedded icons must be UTF-8 SVG") .replace("currentColor", &rgba(foreground)) // The source artwork's blue swatch is the semantic theme accent. // Battery warning/success swatches remain status colors. .replace("#3d7be6", &rgba(accent)) .into_bytes() } fn overview_control( bar: BarMetrics, foreground: Color, ) -> Element<'static, cosmic::Action> { container(bar_icon( include_bytes!("../assets/icons/ferese.svg"), bar.overview_icon_size, foreground, )) .width(bar.overview_icon_size) .height(bar.control_height) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) .into() } #[cfg(test)] fn bar_hidden(snapshot: &ShellSnapshot) -> bool { let output = snapshot .outputs .iter() .find(|output| output.focused) .or_else(|| snapshot.outputs.first()) .map(|output| output.id); output_bar_hidden(snapshot, output) } fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { let active_workspace = snapshot .outputs .iter() .find(|candidate| Some(candidate.id) == output) .map(|output| output.active_workspace); active_workspace.is_some_and(|workspace| { snapshot .windows .iter() .any(|window| window.workspace == workspace && window.fullscreen) }) } fn workspace_indicator( active: bool, occupied: bool, bar: BarMetrics, shell_theme: ShellTheme, ) -> Element<'static, cosmic::Action> { let marker = container(text("")) .width(if active { 18.0 } else { 6.0 }) .height(6) .class(theme::Container::custom(move |_| { workspace_selector_style(active, occupied, shell_theme) })); container(marker) .width(28) .height(bar.control_height) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) .into() } fn workspace_on_bar(workspace_output: Option, bar_output: Option) -> bool { matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar) } fn workspace_selector_style( active: bool, occupied: bool, shell_theme: ShellTheme, ) -> container::Style { container::Style { background: if active { Some(Background::Color(color(shell_theme.accent))) } else if occupied { Some(Background::Color(color_with_opacity( shell_theme.text_primary, 0.78, ))) } else { None }, border: Border { color: color_with_opacity(shell_theme.text_muted, 0.55), width: if !active && !occupied { 1.0 } else { 0.0 }, radius: 3.0.into(), }, ..Default::default() } } fn current_time() -> String { format_bar_time(&Zoned::now()) } fn format_bar_time(now: &Zoned) -> String { let months = [ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec", ]; let hour = now.hour(); let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 }; format!( "{} {}, {}:{:02} {}", now.day(), months[(now.month() - 1) as usize], hour12, now.minute(), if hour < 12 { "am" } else { "pm" } ) } fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style { cosmic::iced::theme::Style { background_color: Color::TRANSPARENT, text_color: color(theme.text_primary), icon_color: color(theme.text_primary), } } fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style { container::Style { icon_color: Some(color(theme.text_primary)), text_color: Some(color(theme.text_primary)), // The compositor paints the selected panel material behind this surface. // A client-side fill would cover its blur and tint; use it only as fallback. background: if compositor_material { None } else { Some(Background::Color(color(theme.bar_background))) }, border: Border { radius: theme.bar_radius.into(), ..Border::default() }, snap: true, ..container::Style::default() } } 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) } fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { let [red, green, blue] = config::default_background(); container::Style { background: Some(Background::Color(Color::from_rgb8(red, green, blue))), ..container::Style::default() } } // Resolve a surface while iced guarantees the native handles are alive. Keep // the borrowed-display connection alive until the effects binding takes over. fn native_wayland_surface( window: &dyn cosmic::iced::window::Window, ) -> Result<(Connection, wl_surface::WlSurface), String> { use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; let display = window.display_handle().map_err(|error| error.to_string())?; let handle = window.window_handle().map_err(|error| error.to_string())?; let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = (display.as_raw(), handle.as_raw()) else { return Err("shell material requires a Wayland surface".to_owned()); }; // SAFETY: iced lends matching, live Wayland handles for this callback. // This backend borrows the display; it never disconnects iced's connection. // Both handles belong to the runtime, which outlives the shell bindings. let backend = unsafe { wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) }; let connection = Connection::from_backend(backend); // SAFETY: raw-window-handle identifies this pointer as a live wl_surface. let id = unsafe { wayland_client::backend::ObjectId::from_ptr( wl_surface::WlSurface::interface(), handle.surface.as_ptr().cast(), ) } .map_err(|error| error.to_string())?; let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?; Ok((connection, surface)) } struct EffectsBinding { connection: Connection, _manager: FereseEffectsManagerV1, surface: FereseSurfaceEffectsV1, _queue: EventQueue, regions: std::cell::RefCell>>, opacity: std::cell::Cell>, } impl EffectsBinding { fn attach( surface: &wl_surface::WlSurface, visible: bool, ) -> Result> { Self::attach_role( surface, visible.then_some(ferese_surface_effects_v1::Role::Panel), ) } fn attach_role( surface: &wl_surface::WlSurface, role: Option, ) -> Result> { let backend = surface .backend() .upgrade() .ok_or("libcosmic Wayland connection is no longer alive")?; let connection = Connection::from_backend(backend); let (globals, queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); let manager = globals.bind::(&qh, 1..=3, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); if let Some(role) = role { effects.set_role(role); } else { effects.clear_role(); if effects.version() >= 3 { effects.set_opacity(0); } } connection.flush()?; Ok(Self { connection, _manager: manager, surface: effects, _queue: queue, regions: Default::default(), opacity: std::cell::Cell::new(None), }) } fn set_regions(&self, regions: &[[i32; 5]]) -> Result<(), Box> { if self.surface.version() < 2 || self.regions.borrow().as_deref() == Some(regions) { return Ok(()); } self.surface.set_regions( regions .iter() .flat_map(|r| r.iter().flat_map(|v| v.to_ne_bytes())) .collect(), ); if regions.is_empty() { self.surface.clear_role(); } else { self.surface .set_role(ferese_surface_effects_v1::Role::Popover); } self.connection.flush()?; *self.regions.borrow_mut() = Some(regions.to_vec()); Ok(()) } fn set_visible(&self, visible: bool) -> Result<(), Box> { self.set_opacity(1.0)?; if visible { self.surface .set_role(ferese_surface_effects_v1::Role::Panel); } else { self.surface.clear_role(); } self.connection.flush()?; Ok(()) } fn set_opacity(&self, opacity: f32) -> Result<(), Box> { let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32; if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) { self.surface.set_opacity(opacity); self.connection.flush()?; self.opacity.set(Some(opacity)); } Ok(()) } } struct EffectsState; impl Dispatch for EffectsState { fn event( _state: &mut Self, _registry: &wl_registry::WlRegistry, _event: wl_registry::Event, _data: &GlobalListContents, _connection: &Connection, _qh: &QueueHandle, ) { } } delegate_noop!(EffectsState: ignore FereseEffectsManagerV1); delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); #[cfg(test)] mod tests { #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ ( "2026-09-28T21:32:00+01:00[Africa/Lagos]", "28 sept, 9:32 pm", ), ( "2026-09-28T00:05:00+01:00[Africa/Lagos]", "28 sept, 12:05 am", ), ( "2026-09-28T12:00:00+01:00[Africa/Lagos]", "28 sept, 12:00 pm", ), ] { assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected); } } use super::*; use crate::control::{OutputSnapshot, WindowSnapshot}; #[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 icons_use_theme_accent_and_preserve_battery_status_colors() { for (source, accent) in [ ( include_bytes!("../assets/icons/status/battery-full.svg").as_slice(), "#63c168", ), ( include_bytes!("../assets/icons/status/battery-50.svg").as_slice(), "#3d7be6", ), ( include_bytes!("../assets/icons/status/battery-25.svg").as_slice(), "#e0654f", ), ( include_bytes!("../assets/icons/status/wifi-full.svg").as_slice(), "#3d7be6", ), ( include_bytes!("../assets/icons/status/control-center.svg").as_slice(), "#3d7be6", ), ] { let svg = String::from_utf8(tinted_svg( source, Color::from_rgb8(205, 214, 244), Color::from_rgb8(203, 166, 247), )) .unwrap(); assert!(!svg.contains("currentColor")); assert!(svg.contains("rgba(205,214,244,1)")); assert!(!svg.contains("#3d7be6")); assert!(svg.contains(if accent == "#3d7be6" { "rgba(203,166,247,1)" } else { accent })); } } #[test] fn workspace_selector_has_distinct_active_paint() { let theme = ShellTheme::default(); let active = workspace_selector_style(true, false, theme); let occupied = workspace_selector_style(false, true, theme); let inactive = workspace_selector_style(false, false, theme); assert!(active.background.is_some()); assert_eq!( active.background, Some(Background::Color(color(theme.accent))) ); assert!(occupied.background.is_some()); assert!(inactive.background.is_none()); assert_eq!(inactive.border.width, 1.0); } #[test] fn workspace_selector_is_output_local() { assert!(workspace_on_bar(Some(1), Some(1))); assert!(!workspace_on_bar(Some(2), Some(1))); assert!(!workspace_on_bar(None, Some(1))); assert!(!workspace_on_bar(None, 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, 13); assert_eq!(metrics.icon_size, 19); assert_eq!(metrics.overview_icon_size, 19); 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))); } 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: 1, workspace, app_id: "dev.ferese.Test".to_owned(), title: "Test".to_owned(), focused: true, fullscreen: true, }], } } }