Refine shell status controls and glass rendering; defer cursor redraw safely

This commit is contained in:
Samuel Onoja committed 2026-09-26 05:05:50 +01:00
1 parent e85fc1e032
commit b3fa82b53b
53 files changed
+3329 -259

No files matched your search

+187 -12
View File
@@ -11,11 +11,34 @@ pub(crate) struct ShellConfig {
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
pub(crate) status: StatusConfig,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub(crate) struct StatusConfig {
pub(crate) battery_percentage: bool,
pub(crate) low_battery_threshold: u8,
pub(crate) settings_command: Option<Vec<String>>,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
battery_percentage: true,
low_battery_threshold: 20,
settings_command: None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme {
pub(crate) bar_background: [u8; 4],
pub(crate) bar_text_primary: [u8; 4],
pub(crate) bar_text_muted: [u8; 4],
pub(crate) surface_base: [u8; 4],
pub(crate) surface_popover: [u8; 4],
pub(crate) text_primary: [u8; 4],
pub(crate) text_muted: [u8; 4],
pub(crate) accent: [u8; 4],
@@ -23,8 +46,10 @@ pub(crate) struct ShellTheme {
pub(crate) shadow: [u8; 4],
pub(crate) bar_height: f32,
pub(crate) bar_margin_top: i32,
pub(crate) bar_window_gap: i32,
pub(crate) bar_margin_horizontal: i32,
pub(crate) bar_radius: f32,
pub(crate) material_radius: f32,
pub(crate) panel_padding: f32,
pub(crate) control_gap: f32,
pub(crate) shadow_offset_y: f32,
@@ -35,18 +60,24 @@ pub(crate) struct ShellTheme {
impl Default for ShellTheme {
fn default() -> Self {
Self {
bar_background: [28, 32, 46, 242],
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_popover: [17, 24, 33, 245],
text_primary: [244, 247, 251, 255],
text_muted: [127, 138, 152, 255],
accent: [91, 140, 255, 255],
border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85],
bar_height: 38.0,
bar_margin_top: 4,
bar_margin_horizontal: 10,
bar_radius: 19.0,
bar_height: 28.0,
bar_margin_top: 0,
bar_window_gap: 8,
bar_margin_horizontal: 0,
bar_radius: 0.0,
material_radius: 14.0,
panel_padding: 12.0,
control_gap: 8.0,
control_gap: 12.0,
shadow_offset_y: 4.0,
shadow_blur: 18.0,
shadow_opacity: 0.20,
@@ -54,6 +85,15 @@ impl Default for ShellTheme {
}
}
impl ShellTheme {
// The bar has its own foreground tokens, matched to its dark glass surface.
pub(crate) fn for_bar(mut self) -> Self {
self.text_primary = self.bar_text_primary;
self.text_muted = self.bar_text_muted;
self
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct WallpaperConfig {
pub(crate) path: Option<PathBuf>,
@@ -71,12 +111,23 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
theme: ThemeConfig,
#[serde(default)]
status: StatusConfig,
}
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f32>,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
surface: SurfaceConfig,
#[serde(default)]
colors: ThemeColorsConfig,
#[serde(default)]
@@ -89,6 +140,50 @@ struct ThemeConfig {
background: WallpaperConfig,
}
#[derive(Debug, Default, Deserialize)]
struct SurfaceConfig {
#[serde(default)]
bar: BarConfig,
#[serde(default)]
popover: PopoverConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct BarConfig {
background: String,
// If omitted, preserve the alpha supplied in `background`.
opacity: Option<f32>,
text_primary: String,
text_muted: String,
}
impl Default for BarConfig {
fn default() -> Self {
Self {
background: "#1C202EF2".to_owned(),
opacity: None,
text_primary: "#F0F3FA".to_owned(),
text_muted: "#AAB4C7".to_owned(),
}
}
}
#[derive(Debug, Deserialize)]
struct PopoverConfig {
#[serde(default = "default_popover_opacity")]
opacity: f32,
}
impl Default for PopoverConfig {
fn default() -> Self {
Self {
opacity: default_popover_opacity(),
}
}
}
const fn default_popover_opacity() -> f32 {
0.96
}
#[derive(Debug, Default, Deserialize)]
struct TypographyConfig {
font_family: Option<String>,
@@ -129,6 +224,8 @@ struct ThemeGeometryConfig {
top_bar_height: f32,
#[serde(default = "default_bar_margin_top")]
top_bar_margin_top: i32,
#[serde(default = "default_bar_window_gap")]
top_bar_window_gap: i32,
#[serde(default = "default_bar_margin_horizontal")]
top_bar_margin_horizontal: i32,
#[serde(default = "default_bar_radius")]
@@ -144,6 +241,7 @@ impl Default for ThemeGeometryConfig {
Self {
top_bar_height: default_bar_height(),
top_bar_margin_top: default_bar_margin_top(),
top_bar_window_gap: default_bar_window_gap(),
top_bar_margin_horizontal: default_bar_margin_horizontal(),
top_bar_radius: default_bar_radius(),
panel_padding: default_panel_padding(),
@@ -188,12 +286,18 @@ pub(crate) fn load() -> ShellConfig {
match toml::from_str::<FereseConfig>(&source) {
Ok(config) => {
let theme = shell_theme(&config.theme);
let mut theme = shell_theme(&config.theme);
theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
ShellConfig {
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
},
}
}
Err(error) => {
@@ -207,7 +311,31 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
if let Some(opacity) = theme.surface.bar.opacity {
background[3] =
(finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0)
.round() as u8;
}
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
.unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
.unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity())
.clamp(0.0, 1.0)
* 255.0)
.round() as u8;
color
},
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
@@ -215,13 +343,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
bar_margin_top: theme.geometry.top_bar_margin_top.max(0),
bar_window_gap: theme.geometry.top_bar_window_gap.max(0),
bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0),
bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius),
panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding),
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
shadow_opacity: theme.shadow.soft.opacity.clamp(0.0, 1.0),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
.clamp(0.0, 1.0),
}
}
@@ -282,22 +412,25 @@ fn default_shadow() -> String {
"#00000055".to_owned()
}
const fn default_bar_height() -> f32 {
38.0
28.0
}
const fn default_bar_window_gap() -> i32 {
8
}
const fn default_bar_margin_top() -> i32 {
4
0
}
const fn default_bar_margin_horizontal() -> i32 {
10
0
}
const fn default_bar_radius() -> f32 {
19.0
0.0
}
const fn default_panel_padding() -> f32 {
12.0
}
const fn default_control_gap() -> f32 {
8.0
12.0
}
const fn default_shadow_offset_y() -> f32 {
4.0
@@ -363,5 +496,47 @@ mod tests {
assert_eq!(config.theme.typography.font_family, None);
assert_eq!(config.theme.background.path, None);
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_margin_top, 0);
assert_eq!(theme.bar_height, 28.0);
assert_eq!(theme.bar_margin_horizontal, 0);
assert_eq!(theme.bar_radius, 0.0);
assert_eq!(theme.bar_background, ShellTheme::default().bar_background);
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
assert_ne!(theme.for_bar().text_primary, theme.text_primary);
}
#[test]
fn bar_palette_can_be_changed_without_changing_popovers() {
let config: FereseConfig = toml::from_str(
r##"
[theme.surface.bar]
background = "#EAECEEDD"
text_primary = "#222222"
text_muted = "#666666"
"##,
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background, [234, 236, 238, 221]);
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
}
#[test]
fn bar_transparency_and_shadow_opacity_are_independent() {
let config: FereseConfig = toml::from_str(
r#"
[theme.surface.bar]
opacity = 0.6
[theme.shadow.soft]
opacity = 0.07
"#,
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background[3], 153);
assert_eq!(theme.shadow_opacity, 0.07);
}
}
+5 -2
View File
@@ -140,8 +140,11 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
// SAFETY: Ferese passes ownership of this inherited descriptor to the
// shell. This function is called once and consumes the descriptor.
let socket = unsafe { UnixStream::from_raw_fd(fd) };
Ok(Connection::from_socket(socket)?)
// Rust's cloned stream is close-on-exec. Do not leak shell-control authority
// into status helpers or applications launched from the menu.
let connection_socket = socket.try_clone()?;
drop(socket);
Ok(Connection::from_socket(connection_socket)?)
}
struct ControlState {
+416 -74
View File
@@ -1,7 +1,16 @@
mod config;
mod control;
mod motion;
mod status;
mod status_ui;
use std::time::Duration;
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};
@@ -39,6 +48,7 @@ struct BarMetrics {
height: f32,
text_size: u16,
icon_size: u16,
overview_icon_size: u16,
control_height: f32,
workspace_hit_width: f32,
active_marker_width: f32,
@@ -53,14 +63,17 @@ impl From<ShellTheme> for BarMetrics {
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,
// 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),
workspace_hit_width: 22.0,
active_marker_width: 14.0,
active_marker_height: 4.0,
dot_marker_size: 4.0,
empty_marker_size: 3.0,
}
}
}
@@ -90,14 +103,28 @@ struct FereseShell {
overview_active: bool,
clock: String,
effects: Option<EffectsBinding>,
status_service: status::Service,
status: status::Snapshot,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
}
#[derive(Clone, Debug)]
enum Message {
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>),
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
}
impl cosmic::Application for FereseShell {
@@ -123,6 +150,10 @@ impl cosmic::Application for FereseShell {
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,
control: ShellControl::connect()
.map_err(|error| {
@@ -167,7 +198,10 @@ impl cosmic::Application for FereseShell {
},
size: Some((None, Some(bar.height.round() as u32))),
size_limits: Limits::NONE,
exclusive_zone: bar.height.round() as i32,
// 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(Self::view_layer)),
@@ -182,13 +216,92 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
event::listen_with(|event, _status, id| Some(Message::Event(event, id))),
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick),
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(..),
)) => 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()) {
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
Subscription::none()
},
])
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
match message {
Message::NativeSurface(id, result) => {
match result {
Ok((_connection, surface)) if id == self.bar_surface_id => {
self.attach_effects(&surface)
}
Ok((_connection, surface)) => {
if let Some(menu) = &mut self.menu
&& menu.id == id
&& menu.effects.is_none()
{
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
}
}
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 => Task::none(),
Message::OpenMenu(kind, anchor) => 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.destroy_menu();
}
Task::none()
}
Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => {
self.clock = current_time();
@@ -216,7 +329,10 @@ impl cosmic::Application for FereseShell {
let input_zone = if hidden { Some(Vec::new()) } else { None };
return set_input_zone(self.bar_surface_id, input_zone);
return Task::batch([
set_input_zone(self.bar_surface_id, input_zone),
self.destroy_menu(),
]);
}
}
}
@@ -238,6 +354,12 @@ impl cosmic::Application for FereseShell {
}
}
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()
}
@@ -250,9 +372,94 @@ impl cosmic::Application for FereseShell {
impl FereseShell {
fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
match event {
Event::Window(window::Event::Opened { .. })
if id == self.bar_surface_id
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
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.destroy_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);
}
wayland::PopupEvent::Focused => {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
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 { .. } => {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
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::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) if id == self.bar_surface_id => {
cosmic::iced::exit()
}
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,
Some(ferese_surface_effects_v1::Role::Popover),
) {
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,
@@ -278,6 +485,8 @@ impl FereseShell {
return;
}
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) {
Ok(binding) => self.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
@@ -299,24 +508,28 @@ 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 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(1)
.align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push(
button::custom(overview_control(bar, shell_theme))
button::custom(overview_control(bar, color(shell_theme.text_primary)))
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.padding([0, 7])
.class(status_ui::button_style(
color(shell_theme.text_primary),
self.overview_active,
1.0,
))
.on_press(cosmic::Action::App(Message::ToggleOverview)),
);
for workspace in self
.snapshot
.workspaces
.iter()
.filter(|workspace| workspace.output == focused_output_id)
.filter(|workspace| self.overview_active && workspace.output == focused_output_id)
{
let occupied = self
.snapshot
@@ -327,7 +540,11 @@ impl FereseShell {
let workspace_button = button::custom(indicator)
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.class(status_ui::button_style(
color(shell_theme.text_primary),
false,
1.0,
))
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id,
)));
@@ -346,36 +563,49 @@ 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(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center);
let center = text(&self.clock).size(bar.text_size);
let right = row![
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),
let foreground = color(shell_theme.text_primary);
let app_name = if self.overview_active {
"Overview".to_owned()
} else {
app_name
};
let left = row![
workspace_row,
text(app_name)
.size(bar.text_size)
.font(cosmic::font::semibold())
.width(Length::Fill)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground))
]
.spacing(shell_theme.control_gap)
.spacing(8)
.align_y(cosmic::iced::Alignment::Center);
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(), clock]
.spacing(10)
.align_y(cosmic::iced::Alignment::Center);
let content = row![
container(left)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Left),
container(center)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Center),
container(right)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Right),
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.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)))
.class(theme::Container::custom(move |_| {
bar_style(shell_theme, compositor_material)
}))
.into()
}
@@ -392,11 +622,16 @@ impl FereseShell {
WallpaperMode::Fit => ContentFit::Contain,
};
image(path.clone())
.width(Length::Fill)
.height(Length::Fill)
.content_fit(content_fit)
.into()
container(
image(path.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()
}
}
@@ -407,29 +642,29 @@ fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon {
.size(size)
}
fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
// COSMIC's transparent containers replace inherited foreground colors.
// Pin the bar glyph color rather than relying on those container defaults.
symbolic_icon(source, size).class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style {
color: Some(foreground),
}
}))
}
fn overview_control(
bar: BarMetrics,
shell_theme: ShellTheme,
foreground: Color,
) -> Element<'static, cosmic::Action<Message>> {
container(symbolic_icon(
include_bytes!("../assets/icons/overview.svg"),
bar.icon_size,
container(bar_icon(
include_bytes!("../assets/icons/ferese.svg"),
bar.overview_icon_size,
foreground,
))
.width(bar.control_height)
.width(bar.overview_icon_size)
.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()
}
@@ -523,28 +758,34 @@ fn display_app_name(window: &control::WindowSnapshot) -> String {
}
fn current_time() -> String {
Zoned::now().strftime("%H:%M").to_string()
Zoned::now().strftime("%a · %H:%M").to_string()
}
fn bar_style(theme: ShellTheme) -> container::Style {
let mut shadow = color(theme.shadow);
shadow.a *= theme.shadow_opacity;
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)),
background: None,
border: Border {
color: color(theme.border),
width: 1.0,
radius: theme.bar_radius.into(),
// 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)))
},
shadow: Shadow {
color: shadow,
offset: Vector::new(0.0, theme.shadow_offset_y),
blur_radius: theme.shadow_blur,
border: Border {
radius: theme.bar_radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
}
}
@@ -566,6 +807,39 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
}
}
// 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,
@@ -577,6 +851,16 @@ 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()
@@ -588,8 +872,8 @@ impl EffectsBinding {
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
if visible {
effects.set_role(ferese_surface_effects_v1::Role::Panel);
if let Some(role) = role {
effects.set_role(role);
} else {
effects.clear_role();
}
@@ -638,6 +922,64 @@ mod tests {
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[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);
+115
View File
@@ -0,0 +1,115 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
use cosmic::iced::advanced::Renderer as _;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> {
Element::new(Motion { content, progress })
}
struct Motion<'a, M> {
content: Element<'a, M>,
progress: f32,
}
impl<M> Motion<'_, M> {
fn translation(&self) -> Vector {
Vector::new(0.0, -4.0 * (1.0 - self.progress))
}
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(p - self.translation())
})
}
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children()
}
fn diff(&mut self, tree: &mut widget::Tree) {
self.content.as_widget_mut().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let cursor = self.cursor(cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let v = self.translation();
let transform = Transformation::translate(v.x, v.y);
renderer.with_transformation(transform, |renderer| {
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(cursor),
viewport,
)
});
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
tree,
layout,
self.cursor(cursor),
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
}
+463
View File
@@ -0,0 +1,463 @@
//! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state.
use std::{
env, fs,
path::Path,
process::{Command, Stdio},
sync::{
Arc, Condvar, Mutex,
mpsc::{self, Receiver, SyncSender},
},
thread,
time::Duration,
};
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
pub network: Option<Network>,
pub bluetooth: Option<Bluetooth>,
pub audio: Option<Audio>,
pub battery: Option<Battery>,
pub brightness: Option<u8>,
pub notifications: Option<Notifications>,
pub poweroff: bool,
pub reboot: bool,
pub suspend: bool,
}
#[derive(Clone, Debug)]
pub struct Network {
pub enabled: bool,
pub connection: Option<String>,
pub signal: u8,
}
#[derive(Clone, Debug)]
pub struct Bluetooth {
pub enabled: bool,
pub devices: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct Audio {
pub volume: u8,
pub muted: bool,
pub output: String,
}
#[derive(Clone, Debug)]
pub struct Battery {
pub percent: u8,
pub status: String,
}
#[derive(Clone, Debug)]
pub struct Notifications {
pub count: u32,
pub dnd: bool,
}
#[derive(Clone, Debug)]
pub enum Action {
Wifi(bool),
Bluetooth(bool),
Volume(u8),
Mute(bool),
Brightness(u8),
Dnd(bool),
Notifications,
Settings,
Poweroff,
Reboot,
Suspend,
}
impl Action {
fn key(&self) -> std::mem::Discriminant<Self> {
std::mem::discriminant(self)
}
}
pub struct Update {
pub snapshot: Snapshot,
pub generation: u64,
pub error: Option<String>,
}
pub struct Service {
tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>,
pub generation: u64,
}
impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self {
let (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
let (updates, rx) = mpsc::sync_channel(1);
// Polling and writes have separate workers: a missing D-Bus service must
// never hold up volume/brightness changes. Publish only coherent polls.
let shared = Arc::new((
Mutex::new((0u64, false, None::<String>, false)),
Condvar::new(),
));
let polling = shared.clone();
thread::spawn(move || {
loop {
let before = {
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
state.0
};
let snapshot = poll();
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
if state.0 == before && !state.1 {
let _ = updates.try_send(Update {
snapshot,
generation: state.0,
error: state.2.clone(),
});
let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
}
}
});
thread::spawn(move || {
loop {
let first = match commands.recv() {
Ok(command) => command,
Err(_) => break,
};
let mut pending = vec![first];
for command in commands.try_iter() {
pending.retain(|(_, action)| action.key() != command.1.key());
pending.push(command);
}
for (id, action) in pending {
{
let mut state = shared.0.lock().unwrap();
state.0 = id;
state.1 = true;
}
let error = execute(&action, settings.as_deref()).err();
let mut state = shared.0.lock().unwrap();
state.1 = false;
state.2 = error;
shared.1.notify_one();
}
}
shared.0.lock().unwrap().3 = true;
shared.1.notify_one();
});
Self {
tx,
rx,
generation: 0,
}
}
pub fn send(&mut self, action: Action) -> Result<(), String> {
let generation = self.generation + 1;
self.tx
.try_send((generation, action))
.map_err(|_| "Controls are busy; please try again".to_owned())?;
self.generation = generation;
Ok(())
}
pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last()
}
}
pub fn available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |p: &Path| {
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
if program.contains('/') {
return executable(Path::new(program));
}
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
fn run(program: &str, args: &[&str]) -> Result<String, String> {
if !available(program) {
return Err(format!("{program} is not installed"));
}
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
let output = Command::new("timeout")
.args(["--kill-after=1s", "2s", program])
.args(args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr)
.trim()
.chars()
.take(180)
.collect::<String>();
return Err(if message.is_empty() {
format!("{program} did not complete successfully")
} else {
message
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn poll() -> Snapshot {
Snapshot {
network: network(),
bluetooth: bluetooth(),
audio: audio(),
battery: battery(),
brightness: brightness(),
notifications: notifications(),
poweroff: can_power("CanPowerOff"),
reboot: can_power("CanReboot"),
suspend: can_power("CanSuspend"),
}
}
fn network() -> Option<Network> {
// No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi).
let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?;
if !devices.lines().any(|line| line == "wifi") {
return None;
}
let enabled = run("nmcli", &["radio", "wifi"]).ok()? == "enabled";
let mut connection = None;
let mut signal = 0;
if enabled {
let rows = run(
"nmcli",
&[
"-t",
"-f",
"IN-USE,SIGNAL,SSID",
"device",
"wifi",
"list",
"--rescan",
"no",
],
)
.ok()?;
for line in rows.lines() {
if let Some((strength, ssid)) = line.strip_prefix("*:").and_then(|s| s.split_once(':'))
{
signal = strength.parse::<u8>().ok()?.min(100);
connection = Some(ssid.replace("\\:", ":").replace("\\\\", "\\"));
break;
}
}
}
Some(Network {
enabled,
connection,
signal,
})
}
fn bluetooth() -> Option<Bluetooth> {
let info = run("bluetoothctl", &["show"]).ok()?;
let enabled = info
.lines()
.find_map(|line| line.trim().strip_prefix("Powered: "))?
== "yes";
let devices = if enabled {
run("bluetoothctl", &["devices", "Connected"])
.ok()?
.lines()
.filter_map(|line| {
line.strip_prefix("Device ")
.and_then(|s| s.split_once(' '))
.map(|(_, name)| name.to_owned())
})
.collect()
} else {
Vec::new()
};
Some(Bluetooth { enabled, devices })
}
pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
let volume = value
.strip_prefix("Volume: ")?
.split_whitespace()
.next()?
.parse::<f32>()
.ok()?;
if !volume.is_finite() || volume < 0.0 {
return None;
}
Some((
(volume * 100.0).round().clamp(0.0, 100.0) as u8,
value.contains("[MUTED]"),
))
}
fn audio() -> Option<Audio> {
let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
let output = info
.lines()
.find_map(|line| line.trim().strip_prefix("node.description = "))
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio {
volume,
muted,
output,
})
}
fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name))
.ok()
.map(|s| s.trim().to_owned())
}
fn battery() -> Option<Battery> {
let entries = fs::read_dir("/sys/class/power_supply").ok()?;
// Prefer a system battery, not a mouse/headset battery.
let mut batteries = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
read(p, "type").as_deref() == Some("Battery")
&& read(p, "scope").as_deref() != Some("Device")
})
.collect::<Vec<_>>();
batteries.sort();
batteries.iter().find_map(|path| {
Some(Battery {
percent: read(path, "capacity")?.parse::<u8>().ok()?.min(100),
status: read(path, "status")?,
})
})
}
fn brightness() -> Option<u8> {
let max = run("brightnessctl", &["--class=backlight", "max"])
.ok()?
.parse::<f64>()
.ok()?;
let current = run("brightnessctl", &["--class=backlight", "get"])
.ok()?
.parse::<f64>()
.ok()?;
(max > 0.0 && current.is_finite())
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
}
fn notifications() -> Option<Notifications> {
// Check service ownership first: swaync-client otherwise waits indefinitely.
let owner = run(
"busctl",
&[
"--user",
"call",
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"NameHasOwner",
"s",
"org.erikreider.swaync",
],
)
.ok()?;
if owner != "b true" {
return None;
}
let count = run("swaync-client", &["--count"]).ok()?.parse().ok()?;
let dnd = match run("swaync-client", &["--get-dnd"]).ok()?.as_str() {
"true" => true,
"false" => false,
_ => return None,
};
Some(Notifications { count, dnd })
}
fn can_power(method: &str) -> bool {
run(
"busctl",
&[
"--system",
"call",
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
method,
],
)
.is_ok_and(|s| s == "s \"yes\"" || s == "s \"challenge\"")
}
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
match action {
Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]),
Action::Bluetooth(on) => run("bluetoothctl", &["power", if *on { "on" } else { "off" }]),
Action::Volume(value) => run(
"wpctl",
&[
"set-volume",
"-l",
"1.0",
"@DEFAULT_AUDIO_SINK@",
&format!("{}%", value.min(&100)),
],
),
Action::Mute(on) => run(
"wpctl",
&[
"set-mute",
"@DEFAULT_AUDIO_SINK@",
if *on { "1" } else { "0" },
],
),
Action::Brightness(value) => run(
"brightnessctl",
&[
"--class=backlight",
"set",
&format!("{}%", value.clamp(&1, &100)),
],
),
Action::Dnd(on) => run(
"swaync-client",
&[if *on { "--dnd-on" } else { "--dnd-off" }],
),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::Poweroff => run("systemctl", &["poweroff"]),
Action::Reboot => run("systemctl", &["reboot"]),
Action::Suspend => run("systemctl", &["suspend"]),
Action::Settings => {
let argv = settings
.filter(|argv| !argv.is_empty())
.ok_or("No settings application configured")?;
let mut command = Command::new(&argv[0]);
command
.args(&argv[1..])
.stdin(Stdio::null())
.env_remove("WAYLAND_SOCKET")
.env_remove("FERESE_SHELL_CONTROL_SOCKET");
if let Some(display) = env::var_os("FERESE_PUBLIC_WAYLAND_DISPLAY") {
command.env("WAYLAND_DISPLAY", display);
}
command.env_remove("FERESE_PUBLIC_WAYLAND_DISPLAY");
let mut child = command.spawn().map_err(|e| e.to_string())?;
thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
}
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_parser_handles_mute_and_rejects_invalid_values() {
assert_eq!(parse_audio("Volume: 0.42 [MUTED]"), Some((42, true)));
assert_eq!(parse_audio("Volume: 1.2"), Some((100, false)));
for value in ["", "Volume: NaN", "Volume: -1", "error"] {
assert_eq!(parse_audio(value), None);
}
}
#[test]
fn default_snapshot_does_not_invent_services() {
let s = Snapshot::default();
assert!(s.network.is_none() && s.audio.is_none() && s.notifications.is_none());
assert!(!s.poweroff && !s.reboot && !s.suspend);
}
}
File diff suppressed because it is too large. Load diff