advance shell integration and session support

This commit is contained in:
Samuel Onoja committed 2026-09-25 09:15:07 +01:00
1 parent 0420242350
commit e85fc1e032
27 files changed
+1165 -201

No files matched your search

+248 -77
View File
@@ -9,6 +9,7 @@ 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::set_input_zone,
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
};
use cosmic::iced::{
@@ -16,7 +17,7 @@ use cosmic::iced::{
window,
};
use cosmic::theme;
use cosmic::widget::{button, container, image, row, text};
use cosmic::widget::{button, container, icon, image, row, text};
use ferese_protocols::effects::v1::client::{
ferese_effects_manager_v1::FereseEffectsManagerV1,
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
@@ -28,21 +29,41 @@ use wayland_client::{
protocol::{wl_registry, wl_surface},
};
use crate::config::{ShellConfig, WallpaperMode};
use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
use crate::control::{ShellControl, ShellSnapshot};
const APP_ID: &str = "dev.ferese.Shell";
const BAR_HEIGHT: u32 = 32;
const BAR_MARGIN: i32 = 4;
const EXCLUSIVE_ZONE: i32 = BAR_HEIGHT as i32;
const BAR_TEXT_SIZE: u16 = (BAR_HEIGHT * 3 / 8) as u16;
const BAR_ICON_SIZE: u16 = (BAR_HEIGHT / 2) as u16;
const CONTROL_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.75;
const WORKSPACE_HIT_WIDTH: f32 = BAR_HEIGHT as f32 * 0.6875;
const ACTIVE_MARKER_WIDTH: f32 = BAR_HEIGHT as f32 * 0.5;
const ACTIVE_MARKER_HEIGHT: f32 = BAR_HEIGHT as f32 * 0.125;
const DOT_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.1875;
const EMPTY_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.125;
#[derive(Clone, Copy)]
struct BarMetrics {
height: f32,
text_size: u16,
icon_size: u16,
control_height: f32,
workspace_hit_width: f32,
active_marker_width: f32,
active_marker_height: f32,
dot_marker_size: f32,
empty_marker_size: f32,
}
impl From<ShellTheme> for BarMetrics {
fn from(theme: ShellTheme) -> Self {
let height = theme.bar_height;
Self {
height,
text_size: (height * 0.368).round() as u16,
icon_size: (height * 0.553).round() as u16,
control_height: height * 0.79,
workspace_hit_width: height * 0.68,
active_marker_width: height * 0.47,
active_marker_height: height * 0.13,
dot_marker_size: height * 0.18,
empty_marker_size: height * 0.13,
}
}
}
fn main() -> cosmic::iced::Result {
let config = config::load();
@@ -66,6 +87,7 @@ struct FereseShell {
config: ShellConfig,
control: Option<ShellControl>,
snapshot: ShellSnapshot,
overview_active: bool,
clock: String,
effects: Option<EffectsBinding>,
}
@@ -75,7 +97,7 @@ enum Message {
Event(Event, window::Id),
Tick,
ActivateWorkspace(u64),
EnterOverview,
ToggleOverview,
}
impl cosmic::Application for FereseShell {
@@ -96,6 +118,8 @@ impl cosmic::Application for FereseShell {
fn init(core: Core, config: Self::Flags) -> (Self, Task<Self::Message>) {
let bar_surface_id = window::Id::unique();
let wallpaper_surface_id = window::Id::unique();
let shell_theme = config.theme;
let bar = BarMetrics::from(shell_theme);
let app = Self {
core,
bar_surface_id,
@@ -106,6 +130,7 @@ impl cosmic::Application for FereseShell {
})
.ok(),
snapshot: ShellSnapshot::default(),
overview_active: false,
clock: current_time(),
effects: None,
};
@@ -135,14 +160,14 @@ impl cosmic::Application for FereseShell {
output: IcedOutput::Active,
namespace: "ferese-shell-top-bar".to_owned(),
margin: IcedMargin {
top: BAR_MARGIN,
right: 10,
top: shell_theme.bar_margin_top,
right: shell_theme.bar_margin_horizontal,
bottom: 0,
left: 10,
left: shell_theme.bar_margin_horizontal,
},
size: Some((None, Some(BAR_HEIGHT))),
size: Some((None, Some(bar.height.round() as u32))),
size_limits: Limits::NONE,
exclusive_zone: EXCLUSIVE_ZONE,
exclusive_zone: bar.height.round() as i32,
..Default::default()
},
Some(Box::new(Self::view_layer)),
@@ -173,8 +198,26 @@ impl cosmic::Application for FereseShell {
if poll.disconnected {
return cosmic::iced::exit();
}
if let Some(active) = poll.overview_active {
self.overview_active = active;
}
if let Some(snapshot) = poll.snapshot {
let was_hidden = bar_hidden(&self.snapshot);
self.snapshot = snapshot;
let hidden = bar_hidden(&self.snapshot);
if hidden != was_hidden {
if let Some(effects) = &self.effects
&& let Err(error) = effects.set_visible(!hidden)
{
eprintln!("ferese-shell: could not update panel material: {error}");
}
let input_zone = if hidden { Some(Vec::new()) } else { None };
return set_input_zone(self.bar_surface_id, input_zone);
}
}
}
Task::none()
@@ -185,9 +228,10 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::EnterOverview => {
Message::ToggleOverview => {
self.overview_active = !self.overview_active;
if let Some(control) = &self.control {
control.enter_overview();
control.set_overview_active(self.overview_active);
}
Task::none()
}
@@ -234,13 +278,20 @@ impl FereseShell {
return;
}
match EffectsBinding::attach(surface) {
match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) {
Ok(binding) => self.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
}
}
fn view_layer(&self) -> Element<'_, cosmic::Action<Message>> {
if bar_hidden(&self.snapshot) {
return container(text(""))
.width(Length::Fill)
.height(Length::Fill)
.into();
}
let focused_output = self
.snapshot
.outputs
@@ -248,16 +299,18 @@ impl FereseShell {
.find(|output| output.focused)
.or_else(|| self.snapshot.outputs.first());
let focused_output_id = focused_output.map(|output| output.id);
let shell_theme = self.config.theme;
let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
.spacing(1)
.align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push(
button::suggested("F")
.font_size(BAR_TEXT_SIZE)
.height(CONTROL_HEIGHT)
.padding([2, 9])
.on_press(cosmic::Action::App(Message::EnterOverview)),
button::custom(overview_control(bar, shell_theme))
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.on_press(cosmic::Action::App(Message::ToggleOverview)),
);
for workspace in self
.snapshot
@@ -270,9 +323,9 @@ impl FereseShell {
.windows
.iter()
.any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator(workspace.active, occupied);
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
let workspace_button = button::custom(indicator)
.height(CONTROL_HEIGHT)
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
@@ -293,16 +346,16 @@ impl FereseShell {
})
.map(display_app_name)
.unwrap_or_else(|| "Desktop".to_owned());
let left = row![workspace_row, text(app_name).size(BAR_TEXT_SIZE)]
.spacing(12)
let left = row![workspace_row, text(app_name).size(bar.text_size)]
.spacing(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center);
let center = text(&self.clock).size(BAR_TEXT_SIZE);
let center = text(&self.clock).size(bar.text_size);
let right = row![
text("◌").size(BAR_ICON_SIZE),
text("♪").size(BAR_ICON_SIZE),
text("●").size(BAR_ICON_SIZE),
symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size),
symbolic_icon(include_bytes!("../assets/icons/volume.svg"), bar.icon_size),
symbolic_icon(include_bytes!("../assets/icons/battery.svg"), bar.icon_size),
]
.spacing(10)
.spacing(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center);
let content = row![
container(left)
@@ -321,8 +374,8 @@ impl FereseShell {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding([0, 12])
.class(theme::Container::custom(bar_style))
.padding([0, shell_theme.panel_padding.round() as u16])
.class(theme::Container::custom(move |_| bar_style(shell_theme)))
.into()
}
@@ -347,51 +400,98 @@ impl FereseShell {
}
}
fn workspace_indicator(active: bool, occupied: bool) -> Element<'static, cosmic::Action<Message>> {
let (width, height, style) = if active {
fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
fn overview_control(
bar: BarMetrics,
shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> {
container(symbolic_icon(
include_bytes!("../assets/icons/overview.svg"),
bar.icon_size,
))
.width(bar.control_height)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.class(theme::Container::custom(move |_| container::Style {
icon_color: Some(color(shell_theme.text_primary)),
background: Some(Background::Color(color(shell_theme.accent))),
border: Border {
color: color_with_opacity(shell_theme.text_primary, 0.14),
width: 1.0,
radius: (bar.control_height * 0.34).into(),
},
snap: true,
..container::Style::default()
}))
.into()
}
fn bar_hidden(snapshot: &ShellSnapshot) -> bool {
let active_workspace = snapshot
.outputs
.iter()
.find(|output| output.focused)
.or_else(|| snapshot.outputs.first())
.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 (width, height, color, radius) = if active {
(
ACTIVE_MARKER_WIDTH,
ACTIVE_MARKER_HEIGHT,
active_workspace_style as fn(&cosmic::Theme) -> container::Style,
bar.active_marker_width,
bar.active_marker_height,
color(shell_theme.accent),
2.0,
)
} else if occupied {
(
DOT_MARKER_SIZE,
DOT_MARKER_SIZE,
occupied_workspace_style as fn(&cosmic::Theme) -> container::Style,
bar.dot_marker_size,
bar.dot_marker_size,
color_with_opacity(shell_theme.text_muted, 0.78),
3.0,
)
} else {
(
EMPTY_MARKER_SIZE,
EMPTY_MARKER_SIZE,
empty_workspace_style as fn(&cosmic::Theme) -> container::Style,
bar.empty_marker_size,
bar.empty_marker_size,
color_with_opacity(shell_theme.text_muted, 0.42),
2.0,
)
};
let marker = container(text(""))
.width(width)
.height(height)
.class(theme::Container::custom(style));
.class(theme::Container::custom(move |_| {
workspace_marker_style(color, radius)
}));
container(marker)
.width(WORKSPACE_HIT_WIDTH)
.height(CONTROL_HEIGHT)
.width(bar.workspace_hit_width)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.into()
}
fn active_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgb8(91, 140, 255), 2.0)
}
fn occupied_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.68), 3.0)
}
fn empty_workspace_style(_theme: &cosmic::Theme) -> container::Style {
workspace_marker_style(Color::from_rgba8(221, 228, 240, 0.25), 2.0)
}
fn workspace_marker_style(color: Color, radius: f32) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
@@ -426,25 +526,37 @@ fn current_time() -> String {
Zoned::now().strftime("%H:%M").to_string()
}
fn bar_style(_theme: &cosmic::Theme) -> container::Style {
fn bar_style(theme: ShellTheme) -> container::Style {
let mut shadow = color(theme.shadow);
shadow.a *= theme.shadow_opacity;
container::Style {
icon_color: Some(Color::from_rgb8(235, 239, 247)),
text_color: Some(Color::from_rgb8(235, 239, 247)),
background: Some(Background::Color(Color::from_rgba8(18, 21, 28, 0.88))),
icon_color: Some(color(theme.text_primary)),
text_color: Some(color(theme.text_primary)),
background: None,
border: Border {
color: Color::from_rgba8(255, 255, 255, 0.14),
color: color(theme.border),
width: 1.0,
radius: 16.0.into(),
radius: theme.bar_radius.into(),
},
shadow: Shadow {
color: Color::from_rgba8(0, 0, 0, 0.32),
offset: Vector::new(0.0, 4.0),
blur_radius: 18.0,
color: shadow,
offset: Vector::new(0.0, theme.shadow_offset_y),
blur_radius: theme.shadow_blur,
},
snap: true,
}
}
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();
@@ -455,13 +567,17 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
}
struct EffectsBinding {
connection: Connection,
_manager: FereseEffectsManagerV1,
_surface: FereseSurfaceEffectsV1,
surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>,
}
impl EffectsBinding {
fn attach(surface: &wl_surface::WlSurface) -> Result<Self, Box<dyn std::error::Error>> {
fn attach(
surface: &wl_surface::WlSurface,
visible: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let backend = surface
.backend()
.upgrade()
@@ -472,15 +588,32 @@ impl EffectsBinding {
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
effects.set_role(ferese_surface_effects_v1::Role::Panel);
if visible {
effects.set_role(ferese_surface_effects_v1::Role::Panel);
} else {
effects.clear_role();
}
connection.flush()?;
Ok(Self {
connection,
_manager: manager,
_surface: effects,
surface: effects,
_queue: queue,
})
}
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
if visible {
self.surface
.set_role(ferese_surface_effects_v1::Role::Panel);
} else {
self.surface.clear_role();
}
self.connection.flush()?;
Ok(())
}
}
struct EffectsState;
@@ -499,3 +632,41 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for EffectsState {
delegate_noop!(EffectsState: ignore FereseEffectsManagerV1);
delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)]
mod tests {
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[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));
}
fn snapshot_with_fullscreen_window(workspace: u64) -> ShellSnapshot {
ShellSnapshot {
outputs: vec![OutputSnapshot {
id: 1,
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,
}],
}
}
}