Files
feresaria/shell/ferese-shell/src/main.rs
T
Samuel Onoja 672b4e2b60 Add live config reload, session locking and managed daemons
Refine focus gradients, shell workspace controls and popup motion. Correct floating stacking and preserve client-chosen dialog sizes. Add lock protocol probes and session integration.
2026-09-26 19:03:37 +01:00

1482 lines
54 KiB
Rust

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::RwLock<cosmic::font::Font>> =
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::collections::HashMap<String, cosmic::font::Font>>,
> = 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<std::borrow::Cow<'a, str>> + '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<ShellTheme> 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::<FereseShell>(settings, (config, wallpaper))
}
type WallpaperLoad =
cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
struct FereseShell {
core: Core,
bar_surface_id: window::Id,
config: ShellConfig,
wallpaper: Option<image::Handle>,
control: Option<ShellControl>,
snapshot: ShellSnapshot,
overview_active: bool,
clock: String,
outputs: Vec<OutputSurfaces>,
status_service: status::Service,
status: status::Snapshot,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
}
struct OutputSurfaces {
output: wl_output::WlOutput,
name: Option<String>,
bar: window::Id,
wallpaper: Option<window::Id>,
effects: Option<EffectsBinding>,
hidden: bool,
}
#[derive(Clone, Debug)]
enum Message {
WallpaperLoaded(Result<image::Handle, String>),
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<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
}
impl cosmic::Application for FereseShell {
type Executor = cosmic::executor::Default;
type Flags = (ShellConfig, Option<WallpaperLoad>);
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<Self::Message>) {
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<Self::Message> {
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<Self::Message> {
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(&regions) {
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<cosmic::iced::theme::Style> {
// 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<Message> {
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<Message> {
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::<Self>(
|_| 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::<Self>(
|_| 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<Message> {
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<Message>> {
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<Message>> {
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<u8> {
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<Message>> {
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<u64>) -> 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<Message>> {
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<u64>, bar_output: Option<u64>) -> 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<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
}
impl EffectsBinding {
fn attach(
surface: &wl_surface::WlSurface,
visible: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::attach_role(
surface,
visible.then_some(ferese_surface_effects_v1::Role::Panel),
)
}
fn attach_role(
surface: &wl_surface::WlSurface,
role: Option<ferese_surface_effects_v1::Role>,
) -> Result<Self, Box<dyn std::error::Error>> {
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::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
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,
}],
}
}
}